From b5e9b03b4d3bb60a51aaba01d7f96651f1548f30 Mon Sep 17 00:00:00 2001 From: nullhack Date: Fri, 3 Apr 2026 00:31:33 -0400 Subject: [PATCH] feat: replace mkdocs with pdoc for API docs, add pytest-html-plus with BDD docstrings - Replace mkdocs/mkdocstrings with pdoc for API documentation - Add pytest-html-plus for BDD-style HTML test reports - Add conftest.py to extract and display Given/When/Then docstrings - Update README with new doc commands (doc-serve, doc-build, doc-publish) - Add BDD docstring guidelines to TDD skill - Update coverage path to docs/coverage (from docs/htmlcov) - Add doc-publish task to push API docs to GitHub Pages --- AGENTS.md | 3 + README.md | 3 +- .../.opencode/agents/developer.md | 33 +++- .../.opencode/skills/code-quality/SKILL.md | 75 ++++++++- .../skills/reference/test-patterns.md | 78 ++++++++- .../.opencode/skills/tdd/SKILL.md | 156 +++++++++++++++++- {{cookiecutter.project_slug}}/AGENTS.md | 77 +++++++-- {{cookiecutter.project_slug}}/README.md | 24 +-- .../docs/docs/index.md | 16 -- .../docs/docs/reference.md | 1 - .../docs/mkdocs.yaml | 44 ----- {{cookiecutter.project_slug}}/pyproject.toml | 47 ++++-- .../tests/basic_test.py | 6 +- .../tests/conftest.py | 126 ++++++++------ .../{{cookiecutter.package_name}}_test.py | 45 +++-- 15 files changed, 544 insertions(+), 190 deletions(-) delete mode 100644 {{cookiecutter.project_slug}}/docs/docs/index.md delete mode 100644 {{cookiecutter.project_slug}}/docs/docs/reference.md delete mode 100644 {{cookiecutter.project_slug}}/docs/mkdocs.yaml diff --git a/AGENTS.md b/AGENTS.md index 700f46d..bd78276 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ When developers use this template, they get: - Comprehensive linting with ruff - Static type checking with pyright - Property-based testing with Hypothesis +- API documentation with pdoc +- BDD-style test reports with pytest-html-plus ## Template Usage @@ -117,6 +119,7 @@ cookiecutter gh:your-username/python-project-template --checkout v1.2.20260312 - **v1.2.20260312**: Added meta template management system - **v1.3.20260313**: Added session-workflow skill - **v1.4.20260313**: Added AI-driven themed naming +- **v1.5.20260402**: Replaced mkdocs with pdoc for API docs, added pytest-html-plus with BDD docstring display ## Generated Project Features diff --git a/README.md b/README.md index 2467d69..35a8390 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ task doc-serve # Live documentation server - Mutation testing with Cosmic Ray **Documentation & Deployment** -- MkDocs with modern theme +- pdoc for API documentation with search +- pytest-html-plus with BDD docstring display - Docker containerization - GitHub Actions CI/CD - Automated documentation deployment diff --git a/{{cookiecutter.project_slug}}/.opencode/agents/developer.md b/{{cookiecutter.project_slug}}/.opencode/agents/developer.md index 3ef1b76..0f58e99 100644 --- a/{{cookiecutter.project_slug}}/.opencode/agents/developer.md +++ b/{{cookiecutter.project_slug}}/.opencode/agents/developer.md @@ -36,17 +36,46 @@ Use `/skill session-workflow` for the complete session start and end protocol. - **Python Version**: >=3.13 ## Project Structure + ``` {{cookiecutter.project_slug}}/ ├── {{cookiecutter.package_name}}/ # Main package +│ ├── __init__.py │ └── {{cookiecutter.module_name}}.py # Entry point -├── tests/ # Test suite -├── docs/ # Documentation +├── tests/ # Test suite (mirror source tree) +│ ├── unit/ +│ │ ├── __init__.py +│ │ ├── domain/ +│ │ │ ├── __init__.py +│ │ │ └── [module]_test.py +│ │ ├── storage/ +│ │ │ ├── __init__.py +│ │ │ └── [adapter]_test.py +│ │ └── models_test.py +│ ├── integration/ +│ │ ├── __init__.py +│ │ └── storage/ +│ │ ├── __init__.py +│ │ ├── factory_test.py +│ │ ├── memory/ +│ │ │ └── [repo]_test.py +│ │ └── sqlite/ +│ │ └── [repo]_test.py +│ ├── conftest.py +│ └── {{cookiecutter.project_slug}}_test.py # Smoke test +├── docs/ # Documentation ├── pyproject.toml # Project config ├── TODO.md # Session state & development roadmap └── README.md # Project docs ``` +### Test Naming Convention +- Use `*_test.py` suffix (e.g., `models_test.py`, not `test_models.py`) +- Configure in `pyproject.toml`: `python_files = ["*_test.py"]` + +### Mirror Source Tree Rule +For each source module `{{cookiecutter.module_name}}//.py`, create a corresponding test file `tests//_test.py`. + ## Coding Standards - Follow PEP 8 style guide - Use Google docstring convention diff --git a/{{cookiecutter.project_slug}}/.opencode/skills/code-quality/SKILL.md b/{{cookiecutter.project_slug}}/.opencode/skills/code-quality/SKILL.md index ffb4d01..e259036 100644 --- a/{{cookiecutter.project_slug}}/.opencode/skills/code-quality/SKILL.md +++ b/{{cookiecutter.project_slug}}/.opencode/skills/code-quality/SKILL.md @@ -174,14 +174,81 @@ def handle_data(processor: DataProcessor) -> None: ``` ### 5. Mutation Testing with Cosmic Ray -```bash -# Run mutation testing (optional, resource-intensive) -task mut-report +Mutation testing validates **test quality** — not just coverage — by introducing small code changes (mutations) and verifying that tests catch them. A test suite with high coverage but a poor mutation score means tests are exercising code without actually verifying behavior. + +#### When to run cosmic-ray +- **After GREEN phase**: when all tests pass and coverage >= minimum +- **Before merging PRs** for core domain logic (models, services, value objects) +- **Not needed for**: storage adapters, web routers, CLI glue code — focus on the domain + +#### Running cosmic-ray + +```bash +# Full mutation report (slow — run once per feature, not per commit) +uv run task mut-report # Generates: docs/mut_report.html + +# Targeted run on a specific module (faster feedback) +uv run cosmic-ray run cosmic-ray.toml {{cookiecutter.module_name}} tests/unit/ --report +uv run cosmic-ray html-report cosmic-ray.toml > docs/mut_report.html +``` + +#### Configuration (`cosmic-ray.toml`) + +```toml +[cosmic-ray] +module-path = "{{cookiecutter.module_name}}" +timeout = 10.0 +excluded-modules = [] # Add web/adapter modules to skip +test-command = "uv run pytest tests/unit/ -x -q" + +[cosmic-ray.distributor] +name = "local" +``` + +#### Interpreting results + +| Metric | Target | Action if below | +|--------|--------|----------------| +| Mutation score | >= 80% | Add property-based tests to kill surviving mutants | +| Survived mutants | < 20% | Investigate with `cosmic-ray show-survivors` | + +```bash +# See which mutants survived (what tests aren't catching) +uv run cosmic-ray show-survivors cosmic-ray.toml ``` -Mutation testing validates test quality by introducing bugs and checking if tests catch them. +#### Fixing surviving mutants + +A surviving mutant means a real bug could go undetected. For each survivor: + +1. Read the mutant diff — what change survived? +2. Write a test that would **fail** with that mutation applied +3. Confirm the new test kills the mutant by re-running + +```python +# Surviving mutant example: +# Original: if value > 0: +# Mutant: if value >= 0: ← survived — no test catches zero boundary + +# Fix: add a boundary test +@given(st.floats(max_value=0.0, allow_nan=False, allow_infinity=False)) +def test_when_value_is_zero_or_negative_should_raise_invalid_value_error(value): + with pytest.raises(InvalidValueError): + MyModel(value=value) +``` + +#### Prioritization — where mutation testing pays off most + +| Code type | Run cosmic-ray? | Reason | +|-----------|-----------------|--------| +| Domain models | Yes — always | Core invariants must be airtight | +| Value objects | Yes — always | Validation logic is critical | +| Domain services | Yes | Business rules live here | +| Repository ports/interfaces | No | No logic to mutate | +| Storage adapters | No | Covered by integration tests | +| Web routers | No | Thin delegation layer | ### 6. Quality Gates and Automation diff --git a/{{cookiecutter.project_slug}}/.opencode/skills/reference/test-patterns.md b/{{cookiecutter.project_slug}}/.opencode/skills/reference/test-patterns.md index f62c26b..e620354 100644 --- a/{{cookiecutter.project_slug}}/.opencode/skills/reference/test-patterns.md +++ b/{{cookiecutter.project_slug}}/.opencode/skills/reference/test-patterns.md @@ -83,8 +83,21 @@ def real_api_response(): ``` ### Property-Based Testing with Hypothesis + +#### When to use Hypothesis vs plain TDD + +| Use plain TDD | Use Hypothesis | +|--------------|----------------| +| Side effects (DB, files, network) | Pure functions | +| Behavioral contracts ("when closed, ceases to exist") | Invariants over all valid inputs | +| Specific error messages | Round-trip properties | +| Integration between components | Algorithms, parsers, serializers | + +**NEVER** use Hypothesis for side-effectful code — it is inefficient and produces flaky tests. + +#### Basic property test ```python -from hypothesis import given, strategies as st +from hypothesis import given, settings, strategies as st @given(st.emails()) def test_when_any_valid_email_provided_should_generate_valid_jwt(email): @@ -96,6 +109,69 @@ def test_when_any_valid_email_provided_should_generate_valid_jwt(email): assert decoded["email"] == email ``` +#### Settings profiles — match intensity to phase +```python +# Fast feedback during RED/GREEN cycle +@settings(max_examples=25, deadline=500) +@given(st.text(min_size=1)) +def test_when_any_input_property_holds(value): ... + +# Thorough check for CI / QA phase +@settings(max_examples=200, deadline=2000) +@given(st.text(min_size=1)) +def test_when_any_input_property_holds_ci(value): ... +``` + +#### Composite strategies — build domain-valid objects +```python +from hypothesis import strategies as st + +@st.composite +def valid_user_data(draw): + """Generate valid user data that satisfies domain rules.""" + return { + "email": draw(st.emails()), + "name": draw(st.text(min_size=1, max_size=100, + alphabet=st.characters(blacklist_characters="\n\r\t"))), + "age": draw(st.integers(min_value=13, max_value=120)), + } + +@given(valid_user_data()) +def test_when_valid_user_data_provided_should_always_create_successfully(data): + user = User.create(**data) + assert user.email == data["email"] +``` + +#### Round-trip invariant — classic Hypothesis use case +```python +@given(st.builds(MyModel, name=st.text(min_size=1))) +def test_when_model_serialized_and_deserialized_should_be_equal(model): + assert MyModel.from_dict(model.to_dict()) == model +``` + +#### Stateful testing — for state machines with interleaved operations +```python +from hypothesis.stateful import RuleBasedStateMachine, rule, initialize, invariant + +class ConnectionMachine(RuleBasedStateMachine): + """Explores all reachable connection state transitions.""" + + @initialize() + def setup(self): + self.conn = Connection.open() + + @rule() + def close(self): + self.conn.close() + + @invariant() + def closed_connection_is_inactive(self): + if self.conn.is_closed(): + assert not self.conn.is_active() + +TestConnectionLifecycle = ConnectionMachine.TestCase +``` + ### Test Fixtures and Factories ```python @pytest.fixture diff --git a/{{cookiecutter.project_slug}}/.opencode/skills/tdd/SKILL.md b/{{cookiecutter.project_slug}}/.opencode/skills/tdd/SKILL.md index 58adad9..1571c16 100644 --- a/{{cookiecutter.project_slug}}/.opencode/skills/tdd/SKILL.md +++ b/{{cookiecutter.project_slug}}/.opencode/skills/tdd/SKILL.md @@ -21,16 +21,170 @@ After running prototypes: 2. Delete prototype directory: `rm -rf prototypes//` 3. Tests read from test file fixtures, NOT from prototype files +## Test Tool Decision Guide + +Choosing the right test tool matters. Applying the wrong one wastes effort. + +### Use plain TDD (example-based) when: +- Testing **side effects**: DB connections, file handles, network sockets, state changes +- Testing **behavioral contracts**: "given connection, when closed, it ceases to exist" +- Testing **error paths** with specific messages +- Testing **integration** between components +- The assertion is about a specific, known outcome + +```python +# RIGHT: plain TDD for side-effectful behavior +def test_connection_closed_should_no_longer_be_active(db_connection): + db_connection.close() + assert not db_connection.is_active +``` + +### Use Hypothesis (property-based) when: +- Testing **pure functions** with well-defined input/output relationships +- Testing **algorithms**: parsers, serializers, transformers, validators +- Testing **invariants** that must hold for *all* valid inputs, not just examples +- Testing **round-trip** properties (encode → decode == original) +- You would otherwise write 5+ similar parametrize tests varying only input values + +```python +# RIGHT: Hypothesis for pure-function invariants +@given(st.text(min_size=1, max_size=100)) +def test_any_valid_name_provided_slugify_should_be_idempotent(name): + slug = slugify(name) + assert slug == slugify(slugify(name)) # idempotent +``` + +### Use Hypothesis stateful testing when: +- Testing **state machines** with many possible operation sequences +- You want to find **unexpected state transitions** from interleaved operations + +### NEVER use Hypothesis for: +- Side effects (DB writes, HTTP calls, file I/O) — it's inefficient and flaky +- Tests that require specific fixture data from prototype output +- Behavioral contracts where the outcome depends on external state + ## TDD Patterns For complete test patterns and guidelines, see: [Reference: Test Patterns](../reference/test-patterns.md) +## BDD Test Docstrings + +All test functions must include Given/When/Then docstrings for the pytest-html-plus report. + +### Format + +```python +def test_condition_should_outcome(): + """ + Given: [precondition or context] + When: [action or trigger] + Then: [expected result] + """ +``` + +### Examples + +```python +def test_empty_input_should_raise_validation_error(): + """ + Given: An empty string input + When: Validation is called + Then: Should raise ValidationError with 'required' message + """ + +def test_valid_email_should_pass_validation(): + """ + Given: A properly formatted email address + When: EmailValidator.validate() is called + Then: Should return True without errors + """ + +def test_divide_by_zero_should_raise_error(): + """ + Given: A Calculator instance + When: divide(10, 0) is called + Then: Should raise ZeroDivisionError + """ +``` + +### Why BDD Docstrings? + +1. **pytest-html-plus**: The HTML report displays docstrings as test names, making it easy to understand what each test verifies +2. **Documentation**: Docstrings serve as living documentation of test intent +3. **Debugging**: When a test fails, the docstring immediately shows what scenario was being tested + +### Multi-line Scenarios + +For complex scenarios, use additional detail in each section: + +```python +def test_federation_created_should_have_active_status(): + """ + Given: A valid federation request with required fields + - name: "Test Federation" + - owner_id: 12345 + - member_count: 3 + When: FederationService.create() is called + Then: Status should be 'active' + Created timestamp should be set + ID should be generated + """ +``` + +## Project Test Structure: Mirror Source Tree + +Tests MUST mirror the source package structure. Each source module gets a corresponding test file. + +**Naming convention**: `*_test.py` suffix (configure in `pyproject.toml`: `python_files = ["*_test.py"]`) + +``` +tests/ +├── unit/ +│ ├── __init__.py # Empty marker +│ ├── domain/ +│ │ ├── __init__.py +│ │ ├── disputes_test.py # Tests for domain/disputes.py +│ │ ├── reputation_test.py # Tests for domain/reputation.py +│ │ └── value_objects_test.py # Tests for domain/value_objects.py +│ ├── storage/ +│ │ ├── __init__.py +│ │ └── sqlite/ +│ │ ├── __init__.py +│ │ ├── sqlite_connection_test.py +│ │ └── sqlite_schema_test.py +│ └── models_test.py # Tests for models.py (enums + dataclasses) +├── integration/ +│ ├── __init__.py # Empty marker +│ └── storage/ +│ ├── __init__.py +│ ├── factory_test.py # Tests for storage/factory.py +│ ├── memory/ +│ │ ├── __init__.py +│ │ └── [entity]_repo_test.py +│ └── sqlite/ +│ ├── __init__.py +│ └── [entity]_repo_test.py +├── conftest.py # Shared fixtures +└── {{cookiecutter.project_slug}}_test.py # Smoke test +``` + +### Mirror Source Tree Rule +For each source module `{{cookiecutter.module_name}}//.py`, create a corresponding test file `tests//_test.py`: + +| Source | Test | +|--------|------| +| `{{cookiecutter.module_name}}/models.py` | `tests/unit/models_test.py` | +| `{{cookiecutter.module_name}}/domain/.py` | `tests/unit/domain/_test.py` | +| `{{cookiecutter.module_name}}/storage/factory.py` | `tests/integration/storage/factory_test.py` | +| `{{cookiecutter.module_name}}/storage/memory/adapters.py` | `tests/integration/storage/memory/*_repo_test.py` | + ## Test Workflow 1. **Write failing test** (RED phase) - - Use descriptive naming: `test_when_[condition]_should_[outcome]` + - Use descriptive naming: `test_[condition]_should_[outcome]` - Embed test data directly in test file + - Place test file in mirror location under `tests/` 2. **Implement minimal code** (GREEN phase) - Just enough to pass the test diff --git a/{{cookiecutter.project_slug}}/AGENTS.md b/{{cookiecutter.project_slug}}/AGENTS.md index b5b1b1b..9c15332 100644 --- a/{{cookiecutter.project_slug}}/AGENTS.md +++ b/{{cookiecutter.project_slug}}/AGENTS.md @@ -44,10 +44,10 @@ This project includes custom skills for OpenCode: ### Development Workflow - **feature-definition**: Define features with SOLID principles and clear requirements - **prototype-script**: Create quick validation scripts with real data capture -- **tdd**: Write comprehensive tests using TDD with pytest/hypothesis +- **tdd**: Write comprehensive tests using TDD with pytest/hypothesis — includes decision guide for when to use plain TDD, Hypothesis (property-based), or Hypothesis stateful testing - **signature-design**: Design modern Python interfaces with protocols and type hints - **implementation**: Implement using TDD methodology with real prototype data -- **code-quality**: Enforce quality with ruff, coverage, and hypothesis testing +- **code-quality**: Enforce quality with ruff, coverage, hypothesis, and cosmic-ray mutation testing ### Repository Management - **git-release**: Create semantic releases with hybrid major.minor.calver versioning and themed naming @@ -73,9 +73,15 @@ uv pip install '.[dev]' # Run the application task run -# Run tests +# Run tests (full suite with coverage report) task test +# Run fast tests only (skip slow tests) +task test-fast + +# Run slow tests only +task test-slow + # Run linting task lint @@ -84,28 +90,71 @@ task static-check # Serve documentation task doc-serve + +# Build documentation +task doc-build ``` -## Package Structure +## Documentation +This project uses **pdoc** for API documentation generation: + +```bash +# Serve documentation locally +task doc-serve + +# Build static documentation with search +task doc-build ``` -{{cookiecutter.project_slug}}/ -├── {{cookiecutter.package_name}}/ # Main package -│ ├── __init__.py -│ └── {{cookiecutter.module_name}}.py -├── tests/ # Test suite -├── docs/ # Documentation (MkDocs) -├── pyproject.toml # Project configuration -├── Dockerfile # Docker image -└── README.md # Project documentation + +Generated docs are in `docs/api/` - open `docs/api/index.html` to browse. + +## Test Conventions + +This project uses BDD-style tests with the following conventions: + +### Test Function Naming +```python +# Format: test__should_ +def test_given__when__then_(): ... +def test__should_(): ... ``` +### BDD Docstrings +All test functions must have Given/When/Then docstrings: +```python +def test_federation_created_should_have_active_status(): + """ + Given: A valid federation with required fields + When: Federation is created + Then: Status should be active + """ +``` + +### Running Tests + +```bash +# Run fast tests (skip slow tests) +task test-fast + +# Run only slow tests +task test-slow + +# Full test suite with coverage +task test +``` + +### Checking Test Compliance +- **pytest-html-plus report**: `docs/tests/report.html` - BDD docstrings displayed as test names +- **Coverage report**: `docs/coverage/index.html` - View coverage by file + ## Code Quality Standards -- **Linting**: ruff with Google style conventions +- **Linting**: ruff with Google style conventions (D205, D212, D415 disabled for test files to allow BDD docstrings) - **Type Checking**: pyright - **Test Coverage**: Minimum {{cookiecutter.minimum_coverage}}% - **Python Version**: >=3.13 +- **Test Markers**: `slow` marks tests >50ms (SQLite, Hypothesis, web routes) ## Release Management diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index adfe243..1b7bf77 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -5,7 +5,7 @@ [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![{{cookiecutter.license}} License][license-shield]][license-url] -[![Coverage](https://img.shields.io/badge/coverage-{{cookiecutter.minimum_coverage}}%25-brightgreen?style=for-the-badge)](docs/htmlcov/index.html) +[![Coverage](https://img.shields.io/badge/coverage-{{cookiecutter.minimum_coverage}}%25-brightgreen?style=for-the-badge)](docs/coverage/index.html) > {{cookiecutter.project_short_description}} @@ -87,16 +87,13 @@ task run # Execute main application task test # Run comprehensive test suite task lint # Format and lint code task static-check # Type safety validation -task doc-serve # Live documentation server +task doc-serve # Live pdoc documentation server +task doc-build # Build static pdoc API docs +task doc-publish # Publish API docs to GitHub Pages # Quality assurance task test-report # Detailed coverage report task mut-report # Mutation testing (optional) -task doc-publish # Deploy documentation - -# Container workflows -docker build --target test -t {{cookiecutter.package_name}}:test -docker build --target prod -t {{cookiecutter.package_name}}:prod ``` ## 🎯 Project Structure @@ -118,7 +115,7 @@ docker build --target prod -t {{cookiecutter.package_name}}:prod │ ├── implementation/ # Guided implementation │ └── code-quality/ # Quality enforcement ├── tests/ # Comprehensive test suite -├── docs/ # Documentation +├── docs/ # Documentation (api/, tests/, coverage/) ├── TODO.md # Development roadmap & session state ├── Dockerfile # Multi-stage container build └── pyproject.toml # Project configuration @@ -130,9 +127,9 @@ docker build --target prod -t {{cookiecutter.package_name}}:prod |----------|-------| | **Package Management** | UV (blazing fast pip/poetry replacement) | | **Code Quality** | Ruff (linting + formatting), PyRight (type checking) | -| **Testing** | PyTest + Hypothesis (property-based testing) | +| **Testing** | PyTest + Hypothesis (property-based testing), pytest-html-plus (BDD reports) | | **AI Integration** | OpenCode agents for development automation | -| **Documentation** | MkDocs with modern theme | +| **Documentation** | pdoc with search functionality | | **Containerization** | Docker with optimized multi-stage builds | ## 📈 Quality Metrics @@ -150,8 +147,11 @@ docker build --target prod -t {{cookiecutter.package_name}}:prod docker build --target prod -t {{cookiecutter.package_name}}:latest . docker run {{cookiecutter.package_name}}:latest -# Documentation deployment -task doc-publish # Deploys to GitHub Pages +# Build API documentation +task doc-build # Generates docs/api/index.html + +# Publish API docs to GitHub Pages +task doc-publish # Pushes docs/api to gh-pages branch # Smart release management @repo-manager /skill git-release diff --git a/{{cookiecutter.project_slug}}/docs/docs/index.md b/{{cookiecutter.project_slug}}/docs/docs/index.md deleted file mode 100644 index e4c6211..0000000 --- a/{{cookiecutter.project_slug}}/docs/docs/index.md +++ /dev/null @@ -1,16 +0,0 @@ -# Welcome to MkDocs - -For full documentation visit [mkdocs.org](https://www.mkdocs.org). - -## Commands - -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. - -## Project layout - - mkdocs.yml - docs/ - index.md diff --git a/{{cookiecutter.project_slug}}/docs/docs/reference.md b/{{cookiecutter.project_slug}}/docs/docs/reference.md deleted file mode 100644 index a38c7f1..0000000 --- a/{{cookiecutter.project_slug}}/docs/docs/reference.md +++ /dev/null @@ -1 +0,0 @@ -::: {{cookiecutter.package_name}} diff --git a/{{cookiecutter.project_slug}}/docs/mkdocs.yaml b/{{cookiecutter.project_slug}}/docs/mkdocs.yaml deleted file mode 100644 index 6e17002..0000000 --- a/{{cookiecutter.project_slug}}/docs/mkdocs.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site_name: {{cookiecutter.project_name}} -site_url: https://{{cookiecutter.github_username}}.github.io/{{cookiecutter.project_slug}} -site_description: {{cookiecutter.project_short_description}} -site_author: {{cookiecutter.full_name}} -site_dir: html -repo_name: {{cookiecutter.github_username}}/{{cookiecutter.project_slug}} -repo_url: https://github.com/{{cookiecutter.github_username}}/{{cookiecutter.project_slug}} - -plugins: - - search - - mkdocstrings: - handlers: - python: - options: - show_submodules: true - show_if_no_docstring: true - modernize_annotations: true - separate_signature: true - docstring_section_style: list - - gen-files: - scripts: - - gen_pages.py - -theme: - name: material - palette: - - media: '(prefers-color-scheme: light)' - scheme: default - primary: blue - accent: amber - atures: - - search.suggest - - search.highlight - - content.tabs.link - icon: - repo: fontawesome/brands/github-alt - #logo: img/logo.svg - #favicon: img/favicon.png - language: en - -nav: - - index.md - - readme.md - - reference.md diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index ed06b5c..2696266 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -22,13 +22,11 @@ Documentation = "https://github.com/{{cookiecutter.github_username}}/{{cookiecut [project.optional-dependencies] dev = [ - "mkdocs>=1.6.1", - "mkdocs-gen-files>=0.5.0", - "mkdocs-material>=9.6.11", - "mkdocstrings[python]>=0.29.1", + "pdoc>=14.0", "pytest>=8.3.5", "pytest-cov>=6.1.1", - "pytest-html>=4.1.1", + "pytest-html-plus>=0.5", + "pytest-mock>=3.14.0", "ruff>=0.11.5", "taskipy>=1.14.1", "hypothesis>=6.148.4", @@ -76,7 +74,7 @@ mccabe.max-complexity = 10 pydocstyle.convention = "google" [tool.ruff.lint.per-file-ignores] -"tests/**" = ["S101"] +"tests/**" = ["S101", "ANN", "D205", "D212", "D415"] [tool.pytest.ini_options] minversion = "6.0" @@ -94,8 +92,12 @@ markers = [ "performance", ] addopts = """ ---maxfail=1 \ +--maxfail=10 \ --color=yes \ +--tb=short \ +-q \ +--html-output=docs/tests \ +--json-report=final_report.json \ """ testpaths = [ "tests", @@ -123,29 +125,40 @@ test-report = """\ pytest \ --doctest-modules \ --cov-config=pyproject.toml \ - --cov-report html:docs/htmlcov \ + --cov-report html:docs/coverage \ --cov-report term:skip-covered \ --cov={{cookiecutter.package_name}} \ --cov-fail-under={{cookiecutter.minimum_coverage}} \ - --html=docs/pytest_report.html \ - --self-contained-html \ --hypothesis-show-statistics \ """ test = """\ python -c "import subprocess, sys; print('Running Smoke Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'smoke']).returncode in (0,5) else 1)" && \ -python -c "import subprocess, sys; print('Running Unit Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'unit']).returncode in (0,5) else 1)" && \ +python -c "import subprocess, sys; print('Running Unit Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'unit', '-m', 'not slow']).returncode in (0,5) else 1)" && \ +python -c "import subprocess, sys; print('Running Integration Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'integration', '-m', 'not slow']).returncode in (0,5) else 1)" && \ python -c "print('Running Tests...');" && \ task test-report\ """ +test-fast = """\ +python -c "import subprocess, sys; print('Running Smoke Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'smoke']).returncode in (0,5) else 1)" && \ +python -c "import subprocess, sys; print('Running Unit Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'unit', '-m', 'not slow']).returncode in (0,5) else 1)" && \ +python -c "import subprocess, sys; print('Running Integration Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'integration', '-m', 'not slow']).returncode in (0,5) else 1)" \ +""" +test-slow = """\ +pytest -m slow \ +""" ruff-check = "ruff check . --fix" ruff-format = "ruff format ." lint = "task ruff-check && task ruff-format" -doc-serve = "mkdocs serve --use-directory-urls -f docs/mkdocs.yaml" -doc-report = "mkdocs build --no-directory-urls -f docs/mkdocs.yaml" -doc-publish = """mkdocs gh-deploy \ ---config-file docs/mkdocs.yaml \ ---no-directory-urls \ ---remote-branch docs""" +doc-serve = "pdoc ./{{cookiecutter.package_name}} --host localhost --port 8080" +doc-build = "pdoc ./{{cookiecutter.package_name}} -o docs/api --search" +doc-publish = """\ +git checkout gh-pages || git checkout -b gh-pages && \ +git rm -rf . && \ +cp -r docs/api/* . && \ +git add -A && \ +git commit -m "Publish API documentation" && \ +git push origin gh-pages --force && \ +git checkout -""" mut-report = """ uv run cosmic-ray new-config mut.toml && \ uv run cosmic-ray init mut.toml mut.sqlite && \ diff --git a/{{cookiecutter.project_slug}}/tests/basic_test.py b/{{cookiecutter.project_slug}}/tests/basic_test.py index 6adea69..7cc8f15 100644 --- a/{{cookiecutter.project_slug}}/tests/basic_test.py +++ b/{{cookiecutter.project_slug}}/tests/basic_test.py @@ -13,7 +13,11 @@ b=st.integers(min_value=-10_000, max_value=10_000).filter(lambda x: x != 0), ) def test_divide_inverse(a: int, b: int) -> None: - """Check that multiplication is the inverse of division (within float tolerance).""" + """ + Given: Two integers a and b where b is non-zero + When: Calculator.divide(a, b) is called + Then: result * b should equal a (within float tolerance) + """ result = m.Calculator.divide(a, b) assert math.isclose(result * b, a, rel_tol=1e-12, abs_tol=1e-12) diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py index 104fd79..86ec9e2 100644 --- a/{{cookiecutter.project_slug}}/tests/conftest.py +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -1,65 +1,87 @@ -import inspect -from typing import Any +"""Pytest configuration for BDD docstring display in HTML reports.""" -from _pytest.config import Config -from _pytest.nodes import Item +import json +import os +import pytest -def pytest_configure(config: Config) -> None: - """Initialize per-session state for docstring printing. - Creates a set on the config object used to track which test - node IDs (without parameterization suffixes) have already had - their docstrings printed. - """ - # use getattr/setattr to avoid static-type warnings about unknown attrs - if getattr(config, "_printed_docstrings", None) is None: - setattr(config, "_printed_docstrings", set()) +def _build_docstring_map() -> dict[str, str]: + """Walk test files and map nodeid → docstring.""" + import ast + from pathlib import Path + mapping: dict[str, str] = {} + tests_dir = Path(__file__).resolve().parent + project_root = tests_dir.parent -def pytest_runtest_setup(item: Item) -> None: - """Print a test function's docstring the first time it is encountered. + for py_file in tests_dir.rglob("*_test.py"): + rel = str(py_file.relative_to(project_root)) + try: + tree = ast.parse(py_file.read_text()) + except (SyntaxError, OSError): + continue - The docstring is printed only once per “base” nodeid. For example, - a parametrized test like ``test_func[param]`` will only have its - docstring printed for the first parameterization. Subsequent cases - skip printing. - """ - tr = item.config.pluginmanager.getplugin("terminalreporter") - if not tr: - return + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name.startswith("test_"): + doc = ast.get_docstring(item) + if doc: + mapping[f"{rel}::{node.name}::{item.name}"] = doc + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name.startswith("test_"): + doc = ast.get_docstring(node) + if doc: + mapping[f"{rel}::{node.name}"] = doc - # strip parameterization suffix: - # "path/to/test.py::test_func[param]" → keep the part before "[" - base_nodeid = item.nodeid.split("[", 1)[0] + return mapping - printed = getattr(item.config, "_printed_docstrings", set()) - if base_nodeid in printed: - return - # obtain the underlying Python object for the test in a safe way - # different pytest versions / stubs expose different attributes; try common ones - obj: Any = getattr(item, "obj", None) or getattr(item, "function", None) or item +@pytest.hookimpl(trylast=True) +def pytest_sessionfinish(session, exitstatus) -> None: + """Add docstrings to JSON and regenerate HTML.""" + html_output = session.config.getoption("--html-output") or "docs/tests" + json_report = session.config.getoption("--json-report") or "final_report.json" + json_path = os.path.join(html_output, json_report) - doc = inspect.getdoc(obj) or "" - if not doc.strip(): - printed.add(base_nodeid) - setattr(item.config, "_printed_docstrings", printed) + try: + with open(json_path) as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): return - # call write_line if available; otherwise fall back to a write() if present - write_line = getattr(tr, "write_line", None) - if callable(write_line): - for line in doc.splitlines(): - write_line(" " + line) - write_line("") - else: - write = getattr(tr, "write", None) - if callable(write): - # write() often expects raw text including newline - for line in doc.splitlines(): - write(" " + line + "\n") - write("\n") - - printed.add(base_nodeid) - setattr(item.config, "_printed_docstrings", printed) + doc_map = _build_docstring_map() + for result in data.get("results", []): + nodeid = result.get("nodeid", "") + + # Strip parametrize suffix like [a-b] for lookup + if "[" in nodeid and nodeid.endswith("]"): + bracket_idx = nodeid.rindex("[") + base_nodeid = nodeid[:bracket_idx] + params = nodeid[bracket_idx + 1 : -1] + else: + base_nodeid = nodeid + params = None + + doc = doc_map.get(base_nodeid) + if doc: + doc_html = doc.replace("\n", "
") + if params: + param_doc = f"Params: ({params.replace('-', ', ')})\n{doc}" + param_html = param_doc.replace("\n", "
") + result["docstring"] = param_doc + result["test"] = param_html + else: + result["docstring"] = doc + result["test"] = doc_html + + with open(json_path, "w") as f: + json.dump(data, f, indent=2) + + from pytest_html_plus.generate_html_report import JSONReporter + + reporter = JSONReporter(json_path, "", html_output) + reporter.load_report() + reporter.generate_html_report() diff --git a/{{cookiecutter.project_slug}}/tests/{{cookiecutter.package_name}}_test.py b/{{cookiecutter.project_slug}}/tests/{{cookiecutter.package_name}}_test.py index 834ed41..6d2ccf2 100644 --- a/{{cookiecutter.project_slug}}/tests/{{cookiecutter.package_name}}_test.py +++ b/{{cookiecutter.project_slug}}/tests/{{cookiecutter.package_name}}_test.py @@ -10,6 +10,10 @@ * Prefer responses over mocking outbound HTTP requests * Prefer tmpdir over global test artifacts +BDD Test Convention: + * Use descriptive naming: test__should_ + * All tests should have Given/When/Then docstrings + """ from typing import Self @@ -40,13 +44,10 @@ def fixt(self: Self) -> int: return 123 def test_one(self: Self, param1: str, param2: str, fixt: int) -> None: - """Run the first test using the fixture. - - Args: - param1 (str): First parameter. - param2 (str): Second parameter. - fixt (int): Value from fixture. - + """ + Given: Two different string parameters + When: Test executes + Then: Parameters should not be equal """ assert param1 != param2 @@ -60,13 +61,10 @@ def test_one(self: Self, param1: str, param2: str, fixt: int) -> None: ], ) def test_divide_ok(a: float, b: float, expected: float) -> None: - """Check if divide works for expected entries. - - Args: - a (float): Dividend. - b (float): Divisor. - expected (float): expected result. - + """ + Given: Valid division inputs + When: Calculator.divide(a, b) is called + Then: Should return expected result """ assert m.Calculator.divide(a, b) == expected @@ -82,15 +80,10 @@ def test_divide_ok(a: float, b: float, expected: float) -> None: def test_divide_error( a: str | float, b: str | float, expected: float | Exception ) -> None: - """Check if divide returns correct Exceptions for known entries. - - Issue raised by https://github.com/{{cookiecutter.github_username}}/{{cookiecutter.project_slug}}/issues/1337 - - Args: - a (float): Dividend. - b (float): Divisor. - expected (Exception): expected Exception. - + """ + Given: Invalid division inputs + When: Calculator.divide(a, b) is called + Then: Should raise expected Exception """ with pytest.raises(expected): m.Calculator.divide(a, b) @@ -99,7 +92,11 @@ def test_divide_error( def test_basics() -> None: - """A test that is always True.""" + """ + Given: A simple test + When: Test executes + Then: Should always pass + """ assert True is True {% endif %}