Coding guidelines for the buildkite-test-collector Python project - a pytest plugin that collects test execution data and sends it to Buildkite Test Engine.
Tech Stack: Python >=3.9, pytest >=7, uv for dependency management
curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv
uv sync --all-extras # Install dependenciesuv run pytest # Run all tests
uv run pytest tests/buildkite_test_collector/collector/test_api.py # Single file
uv run pytest tests/.../test_api.py::test_submit_local_returns_none # Single test
uv run pytest -v # Verbose output
uv run pytest -s # Show print statementsuv run pylint src/ # Lint all (required before PR merge)
uv run pylint src/buildkite_test_collector/collector/api.py # Single fileuv build # Build distribution packages- Order: Standard library → Third-party → Local imports (blank line between groups)
- Style: Absolute imports preferred; relative imports (
..) acceptable within package
- Indentation: 4 spaces (never tabs); Line endings: LF; Quotes: Double (
") - Trailing whitespace: Remove all; Final newline: Always include
- Required: All new code must include type hints
- Common types:
Optional,Dict,List,Tuple,Literal,Mapping,Generator - Type aliases: Use for complex types (e.g.,
JsonValue,JsonDict)
- Classes:
PascalCase(e.g.,BuildkitePlugin,TestData) - Functions/methods:
snake_case(e.g.,pytest_runtest_logstart) - Constants:
UPPER_SNAKE_CASE(e.g.,ENV_TOKEN,DEFAULT_API_URL) - Private methods:
_single_underscoreprefix; Special:__double_underscore__only
- Required: All modules, classes, and public methods
- Style: Imperative mood (e.g., "Submit a payload" not "Submits a payload")
- Format: Triple quotes, concise one-liner preferred
- Default: Use
@dataclass(frozen=True)for immutability - Import:
from dataclasses import dataclass, replace, field - Modification: Use
replace()to create modified copies - Validation: Use
__post_init__for input validation
@dataclass(frozen=True)
class TestData:
id: UUID
scope: str
name: str
def passed(self):
"""Return new instance with passed status"""
return replace(self, result=TestResultPassed())- Specific exceptions: Catch specific exceptions when possible
- Broad exceptions: Use
except Exception:with# pylint: disable=broad-exceptcomment - Logging: Always log warnings/errors with
logger.warning() - Example:
try:
response = post(url, json=data, timeout=60)
response.raise_for_status()
except HTTPError as err:
logger.warning("Failed to upload test results to buildkite")
logger.warning(err)
except Exception: # pylint: disable=broad-except
logger.warning(traceback.format_exc())- Test files:
test_*.pyprefix (e.g.,test_api.py) - Test functions:
test_*prefix (e.g.,test_submit_local_returns_none) - Fixtures: Define in
conftest.py, use extensively - Assertions: Simple
assertstatements - HTTP mocking: Use
@responses.activatedecorator withresponseslibrary - Conditional tests:
@pytest.mark.skipiffor version-specific tests
Use inline disables sparingly with specific error codes:
# pylint: disable=too-few-public-methods- simple data classes# pylint: disable=broad-except- generic exception catching# pylint: disable=unused-argument- required but unused pytest hook parameters- Place at end of line or on line before the violation
TestData and related objects are frozen dataclasses. Always reassign after modifications:
test_data = self.in_flight[nodeid]
test_data = test_data.passed() # Returns NEW instance
self.in_flight[nodeid] = test_data # MUST reassignFollow pytest naming strictly. Document which hook you're implementing:
def pytest_runtest_logreport(self, report):
"""pytest_runtest_logreport hook callback to get test outcome after test call"""
# ImplementationKey hooks: pytest_configure, pytest_runtest_logstart, pytest_runtest_logreport, pytest_runtest_makereport
Define as class constants and access safely:
class API:
ENV_TOKEN = "BUILDKITE_ANALYTICS_TOKEN"
DEFAULT_API_URL = "https://analytics-api.buildkite.com/v1"
def __init__(self, env: Mapping[str, Optional[str]]):
self.token = env.get(self.ENV_TOKEN)
self.api_url = env.get(self.ENV_API_URL) or self.DEFAULT_API_URL- Forgetting to reassign frozen dataclasses:
test_data.passed()returns new instance - must reassign - Missing type hints: All new code requires type hints
- Wrong import order: Standard library → Third-party → Local (with blank lines)
- Skipping docstrings: All public modules/classes/methods need docstrings
- Update version in
pyproject.toml - Create PR with
[release]in title - Merge triggers automated PyPI release
- Create GitHub release tag manually