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
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ jobs:
run: |
uv sync --dev

# - name: Run code formatting check
# run: |
# uv run ruff format --check .
# - name: Run linting
# run: |
# uv run ruff check .
- name: Run code formatting check
run: |
uv run ruff format --check src tests example

- name: Run linting
run: |
uv run ruff check --no-fix src

- name: Run type checking
run: |
uv run mypy
Expand Down
38 changes: 19 additions & 19 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,48 +63,48 @@ jobs:
echo "Version $VERSION not found on PyPI (HTTP $HTTP_CODE)"
fi

- name: Create version tag
if: steps.check_published.outputs.tag_exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release version ${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"

- name: Set up build environment
if: steps.check_published.outputs.pypi_exists == 'false'
run: |
uv sync --dev

- name: Run pipeline (tests, linting, etc.)
if: steps.check_published.outputs.pypi_exists == 'false'
run: make pipeline
run: make pipeline-ci

- name: Build package
if: steps.check_published.outputs.pypi_exists == 'false'
run: make build

- name: Publish to PyPI
if: steps.check_published.outputs.pypi_exists == 'false'
env:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
if [ -z "$PYPI_TOKEN" ]; then
if [ -z "$UV_PUBLISH_TOKEN" ]; then
echo "❌ PYPI_TOKEN secret is not set. Please configure it in repository settings."
echo " Settings → Secrets and variables → Actions → New repository secret"
exit 1
fi
echo $PYPI_TOKEN > /tmp/pypi_token.txt
uv publish --token $(cat /tmp/pypi_token.txt)
rm /tmp/pypi_token.txt

uv publish

- name: Verify publication
if: steps.check_published.outputs.pypi_exists == 'false'
run: |
sleep 10 # Wait for PyPI to index the package
uv run --with pytest-api-cov --no-project -- python -c \
"import pytest_api_cov; print(f'✅ Published version: {pytest_api_cov.__version__}')"


# Tag only after tests, build, and publish have succeeded, so a failed
# release never leaves a version tag pointing at an unreleased commit.
- name: Create version tag
if: steps.check_published.outputs.tag_exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release version ${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"

- name: Skip publish - already published
if: steps.check_published.outputs.pypi_exists == 'true'
run: |
Expand Down
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Makefile

.PHONY: ruff mypy test clean clean-all version
.PHONY: ruff mypy test clean clean-all version check pipeline pipeline-ci

version:
@uv version
Expand All @@ -21,6 +21,13 @@ vulture:

format: ruff mypy vulture

check:
@echo "Running non-mutating checks (format, lint, types, dead code)..."
@uv run ruff format --check src tests example
@uv run ruff check --no-fix src
@uv run mypy
@uv run vulture

test:
@echo "Running plugin tests..."
@uv run python -u -m pytest tests/
Expand Down Expand Up @@ -60,3 +67,7 @@ build:

pipeline: format test cover typeguard test-example test-example-parallel

# CI/publish variant: verifies without rewriting any files, so the built
# artifact always matches the commit being released.
pipeline-ci: check test cover typeguard test-example test-example-parallel

29 changes: 12 additions & 17 deletions PUBLISH.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ Main publishing workflow triggered on pushes to master/main.
**Key features:**
- Reads version from `pyproject.toml`
- Checks if already published (skips if duplicate version)
- Creates Git tag automatically
- Runs full test pipeline
- Runs the full non-mutating check pipeline (`make pipeline-ci`)
- Builds and publishes to PyPI
- Verifies publication
- Creates the Git tag last, only after a successful publish

### `.github/workflows/ci.yml`

Continuous integration for pull requests.

**Key features:**
- Runs on multiple Python versions (3.10, 3.11, 3.12)
- Runs on multiple Python versions (3.10 - 3.14)
- Runs on multiple OS (Ubuntu, Windows, macOS)
- Checks code formatting
- Runs linting
Expand Down Expand Up @@ -148,30 +148,25 @@ The workflows use `uv version` (available in uv 0.8+) to extract the version dir
If you need to publish manually:

```bash
# Run the full pipeline
make pipeline
# Run the full non-mutating pipeline (what CI runs before publishing)
make pipeline-ci

# Build the package
make build

# Set your PyPI token
export PYPI_TOKEN="your-token-here"
echo $PYPI_TOKEN > .pypi_token

# Publish
make publish
# Publish (uv reads the token from UV_PUBLISH_TOKEN)
export UV_PUBLISH_TOKEN="your-token-here"
uv publish
```

## Publishing to Test PyPI

To publish to Test PyPI instead:
To publish to Test PyPI instead, uncomment the `[[tool.uv.index]]` testpypi
section in `pyproject.toml`, then:

```bash
# Set your Test PyPI token
echo $TEST_PYPI_TOKEN > .test_pypi_token

# Publish to Test PyPI
make publish-test
export UV_PUBLISH_TOKEN="your-test-pypi-token"
uv publish --index testpypi
```

Or create a separate workflow by copying `.github/workflows/publish.yml` and modifying it to use `TEST_PYPI_TOKEN` and the `--index testpypi` flag.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,15 +459,15 @@ When using `--api-cov-report-path`, the plugin generates a detailed JSON report:
"excluded_count": 0,
"detail": [
{
"endpoint": "/",
"endpoint": "GET /",
"callers": ["test_root_endpoint"]
},
{
"endpoint": "/users/{user_id}",
"endpoint": "GET /users/{user_id}",
"callers": ["test_get_user"]
},
{
"endpoint": "/health",
"endpoint": "GET /health",
"callers": []
}
]
Expand Down
26 changes: 23 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
[project]
name = "pytest-api-cov"
version = "1.3.8"
version = "1.4.0"
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
readme = "README.md"
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
license = { text = "Apache-2.0" }
requires-python = ">=3.10"
keywords = ["pytest", "plugin", "api", "coverage", "testing", "fastapi", "flask", "django"]
classifiers = [
"Development Status :: 4 - Beta",
"Framework :: Pytest",
"Framework :: Flask",
"Framework :: Django",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Testing",
"Typing :: Typed",
]

dependencies = [
"pydantic>=2.0.0",
"rich>=10.0.0",
"tomli>=1.2.0",
"pytest>=6.0.0",
# pytest.Parser / pytest.Config are referenced at import time and only exist from pytest 7.0.
"pytest>=7.0.0",
"PyYAML>=6.0",
"backports.strenum>=1.3.1; python_version < '3.11'",
]
Expand All @@ -23,6 +43,7 @@ flask = ["flask>=2.0.0"]

[project.urls]
Source = "https://github.com/BarnabasG/api-coverage"
Issues = "https://github.com/BarnabasG/api-coverage/issues"

[dependency-groups]
dev = [
Expand All @@ -49,7 +70,6 @@ fail_under = 70
show_covered_endpoints = true
show_uncovered_endpoints = true
show_excluded_endpoints = true
exclusion_patterns = ["xyz"]
report_path = "reports/pytest_api_cov.json"

[tool.pytest.ini_options]
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_api_cov/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str:
"""Generate example conftest.py content for a given framework."""
module_path = file_path.replace("/", ".").replace("\\", ".").replace(".py", "")
module_path = file_path.replace("/", ".").replace("\\", ".").removesuffix(".py")

if framework == "FastAPI":
test_client_import = "from fastapi.testclient import TestClient"
Expand Down
13 changes: 9 additions & 4 deletions src/pytest_api_cov/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import tomli
from pydantic import BaseModel, ConfigDict, Field

DEFAULT_CLIENT_FIXTURE_NAMES = ("client", "test_client", "api_client", "app_client")


class ApiCoverageReportConfig(BaseModel):
"""Configuration model for API coverage reporting."""
Expand All @@ -24,7 +26,7 @@ class ApiCoverageReportConfig(BaseModel):
force_sugar: bool = Field(default=False, alias="api-cov-force-sugar")
force_sugar_disabled: bool = Field(default=False, alias="api-cov-force-sugar-disabled")
client_fixture_names: list[str] = Field(
["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names"
default_factory=lambda: list(DEFAULT_CLIENT_FIXTURE_NAMES), alias="api-cov-client-fixture-names"
)
group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint")
openapi_spec: str | None = Field(None, alias="api-cov-openapi-spec")
Expand Down Expand Up @@ -60,15 +62,18 @@ def read_toml_config(rootdir: Path | None = None) -> dict[str, Any]:
"api-cov-openapi-spec": "openapi_spec",
}

_UNSET: tuple[Any, ...] = (None, [], False)

def _is_unset(value: Any) -> bool:
"""Detect argparse defaults (None, empty list, False) without equating 0/0.0 to False."""
return value is None or value is False or (isinstance(value, list) and not value)


def read_session_config(session_config: Any) -> dict[str, Any]:
"""Read configuration from pytest session config (command-line flags)."""
config: dict[str, Any] = {}
for opt, key in _CLI_OPTIONS.items():
value = session_config.getoption(f"--{opt}")
if value not in _UNSET:
if not _is_unset(value):
config[key] = value

if session_config.getoption("--api-cov-hide-uncovered-endpoints"):
Expand All @@ -81,7 +86,7 @@ def supports_unicode() -> bool:
"""Check if the terminal supports Unicode output."""
if not sys.stdout.isatty():
return False
return sys.stdout.encoding.lower() in ("utf-8", "utf8")
return (sys.stdout.encoding or "").lower() in ("utf-8", "utf8")


def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportConfig:
Expand Down
Loading
Loading