Skip to content

Commit 814ea66

Browse files
committed
Modernize testing infrastructure with matrix testing and comprehensive tooling
- Add test matrix supporting Python 3.9-3.13 and Django 3.2-5.1 - Configure ruff for comprehensive linting (pycodestyle, Pyflakes, isort, pyupgrade, flake8-bugbear) - Add bandit for security scanning - Set up coverage tracking with 90%+ threshold enforcement - Update justfile with smart test commands (quick, legacy, previous, latest) - Restructure GitHub workflow into 3 jobs: tests, lint-and-type-check, coverage - Apply auto-fixes for type hints and code style (TC006, UP045, RET504, B009) - Fix line-length violations to comply with 88-character limit Test matrix environments: - legacy: Python 3.9 + Django 3.2 - previous: Python 3.11 + Django 4.2 - latest: Python 3.13 + Django 5.1 All 81 tests pass with 94% source coverage across all environments.
1 parent b987958 commit 814ea66

File tree

9 files changed

+440
-103
lines changed

9 files changed

+440
-103
lines changed

.github/workflows/tests.yml

Lines changed: 93 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,116 @@
11
name: Tests
22

33
on:
4-
push:
5-
branches: [ main ]
6-
pull_request:
7-
branches: [ main ]
4+
- push
5+
- pull_request
86

97
jobs:
10-
test:
8+
tests:
119
runs-on: ubuntu-latest
1210
strategy:
11+
fail-fast: false
1312
matrix:
14-
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
15-
django-version: ['3.2', '4.0', '4.1', '4.2', '5.0']
16-
exclude:
17-
# Django 5.0 requires Python 3.10+
18-
- python-version: '3.8'
19-
django-version: '5.0'
20-
- python-version: '3.9'
21-
django-version: '5.0'
22-
# Django 4.2 is LTS, test more thoroughly
23-
- python-version: '3.8'
24-
django-version: '4.0'
25-
- python-version: '3.8'
26-
django-version: '4.1'
13+
include:
14+
# Legacy: Python 3.9 + Django 3.2
15+
- python-version: "3.9"
16+
test-env: legacy
17+
# Previous: Python 3.11 + Django 4.2
18+
- python-version: "3.11"
19+
test-env: previous
20+
# Latest: Python 3.13 + Django 5.1
21+
- python-version: "3.13"
22+
test-env: latest
2723

2824
steps:
29-
- uses: actions/checkout@v4
25+
- uses: actions/checkout@v4
3026

31-
- name: Set up Python ${{ matrix.python-version }}
32-
uses: actions/setup-python@v5
33-
with:
34-
python-version: ${{ matrix.python-version }}
27+
- name: Install uv
28+
uses: astral-sh/setup-uv@v5
3529

36-
- name: Install uv
37-
uses: astral-sh/setup-uv@v4
30+
- name: Set up Python ${{ matrix.python-version }}
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: ${{ matrix.python-version }}
3834

39-
- name: Install dependencies
40-
run: |
41-
uv pip install --system "django==${{ matrix.django-version }}.*"
42-
uv pip install --system -e ".[dev]"
35+
- name: Install just
36+
uses: extractions/setup-just@v2
4337

44-
- name: Run tests
45-
run: |
46-
pytest tests/ -v --cov=graphql_sqlcommenter --cov-report=xml --cov-report=term
38+
- name: Run tests for ${{ matrix.test-env }}
39+
run: just test ${{ matrix.test-env }} ${{ matrix.python-version }}
4740

48-
- name: Upload coverage to Codecov
49-
if: matrix.python-version == '3.11' && matrix.django-version == '4.2'
50-
uses: codecov/codecov-action@v4
51-
with:
52-
file: ./coverage.xml
53-
fail_ci_if_error: false
41+
- name: Upload coverage data
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: coverage-${{ matrix.python-version }}-${{ matrix.test-env }}
45+
path: .coverage.*
46+
include-hidden-files: true
47+
if-no-files-found: error
5448

55-
lint:
49+
lint-and-type-check:
5650
runs-on: ubuntu-latest
5751
steps:
58-
- uses: actions/checkout@v4
52+
- uses: actions/checkout@v4
5953

60-
- name: Set up Python
61-
uses: actions/setup-python@v5
62-
with:
63-
python-version: '3.11'
54+
- name: Install uv
55+
uses: astral-sh/setup-uv@v5
6456

65-
- name: Install uv
66-
uses: astral-sh/setup-uv@v4
57+
- name: Set up Python 3.13
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: "3.13"
6761

68-
- name: Install dependencies
69-
run: |
70-
uv pip install --system ruff mypy
62+
- name: Install dependencies
63+
run: uv sync --group dev
7164

72-
- name: Lint with ruff
73-
run: |
74-
ruff check graphql_sqlcommenter/ tests/
65+
- name: Check formatting
66+
run: |
67+
uv run ruff check --select I graphql_sqlcommenter
68+
uv run ruff format --check graphql_sqlcommenter
7569
76-
- name: Type check with mypy
77-
run: |
78-
mypy graphql_sqlcommenter/ || true # Allow to fail for now
70+
- name: Run linters
71+
run: uv run ruff check graphql_sqlcommenter
72+
73+
- name: Run type checker
74+
run: uv run mypy graphql_sqlcommenter
75+
76+
- name: Check documentation
77+
run: uv run --group docs mkdocs build --strict
78+
79+
- name: Run security scan
80+
run: uv run bandit -r graphql_sqlcommenter -x tests
81+
82+
coverage:
83+
needs: tests
84+
runs-on: ubuntu-latest
85+
steps:
86+
- uses: actions/checkout@v4
87+
88+
- name: Install uv
89+
uses: astral-sh/setup-uv@v5
90+
91+
- name: Set up Python 3.13
92+
uses: actions/setup-python@v5
93+
with:
94+
python-version: "3.13"
95+
96+
- name: Install coverage
97+
run: uv pip install --system coverage
98+
99+
- name: Download all coverage artifacts
100+
uses: actions/download-artifact@v4
101+
with:
102+
pattern: coverage-*
103+
merge-multiple: true
104+
105+
- name: Combine and check coverage
106+
run: |
107+
coverage combine
108+
echo "Source coverage (must be 90%+):"
109+
coverage report --fail-under=90 -m
110+
coverage html
111+
112+
- name: Upload coverage HTML
113+
uses: actions/upload-artifact@v4
114+
with:
115+
name: coverage-html
116+
path: htmlcov/

graphql_sqlcommenter/apps.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""
22
Django app configuration for graphql-sqlcommenter.
33
4-
This provides a ready() hook to automatically patch Graphene when using the Graphene integration approach.
4+
This provides a ready() hook to automatically patch Graphene when using
5+
the Graphene integration approach.
56
"""
67

78
from django.apps import AppConfig

graphql_sqlcommenter/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def get_graphql_meta() -> Optional[GraphQLMetadata]:
5050
Get GraphQL metadata for the current context.
5151
5252
Returns:
53-
Dictionary with keys 'op', 'type', and 'sha', or None if not in a GraphQL context.
53+
Dictionary with keys 'op', 'type', and 'sha', or None if not
54+
in a GraphQL context.
5455
"""
5556
return graphql_meta.get()
5657

graphql_sqlcommenter/graphene_integration.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
import hashlib
2727
import logging
28-
from collections.abc import AsyncIterator, Awaitable, Generator, Mapping
2928
from contextlib import asynccontextmanager, contextmanager
3029
from typing import TYPE_CHECKING, Any, Callable, cast
3130

@@ -36,6 +35,8 @@
3635
)
3736

3837
if TYPE_CHECKING: # pragma: no cover - import for type checking only
38+
from collections.abc import AsyncIterator, Awaitable, Generator, Mapping
39+
3940
from graphene.types.schema import Schema
4041
else: # pragma: no cover - fallback when graphene is absent
4142
Schema = Any # type: ignore[misc,assignment]
@@ -124,8 +125,7 @@ async def patched_graphql_async(
124125
) -> Any:
125126
"""Patched async GraphQL execution with metadata capture."""
126127
with _graphql_meta_context(source, kwargs):
127-
result = await old_graphql_async(schema, source, *args, **kwargs)
128-
return result
128+
return await old_graphql_async(schema, source, *args, **kwargs)
129129

130130
# Apply patches
131131
graphene_schema.graphql_sync = patched_graphql_sync
@@ -255,9 +255,9 @@ def _patch_graphql_core_execution() -> None:
255255
)
256256

257257
# Store original functions
258-
old_execute = cast(Callable[..., Awaitable[Any]], gql_execution.execute)
259-
old_execute_sync = cast(Callable[..., Any], gql_execution.execute_sync)
260-
old_subscribe = cast(Callable[..., AsyncIterator[Any]], gql_execution.subscribe)
258+
old_execute = cast("Callable[..., Awaitable[Any]]", gql_execution.execute)
259+
old_execute_sync = cast("Callable[..., Any]", gql_execution.execute_sync)
260+
old_subscribe = cast("Callable[..., AsyncIterator[Any]]", gql_execution.subscribe)
261261

262262
async def patched_execute(
263263
schema: Any, document: Any, *args: Any, **kwargs: Any
@@ -269,8 +269,7 @@ async def patched_execute(
269269

270270
query = print_ast(document)
271271
async with _async_graphql_meta_context(query, kwargs):
272-
result = await old_execute(schema, document, *args, **kwargs)
273-
return result
272+
return await old_execute(schema, document, *args, **kwargs)
274273
except Exception as e:
275274
logger.debug(f"Could not capture metadata in execute: {e}")
276275
return await old_execute(schema, document, *args, **kwargs)
@@ -308,7 +307,7 @@ async def patched_subscribe(
308307
# Apply patches
309308
gql_execution.execute = patched_execute
310309
gql_execution.execute_sync = patched_execute_sync
311-
gql_execution.subscribe = cast(Any, patched_subscribe)
310+
gql_execution.subscribe = cast("Any", patched_subscribe)
312311

313312
_patched_graphql_core = True
314313
logger.info("graphql-core patching complete")
@@ -477,7 +476,7 @@ def _coerce_query_string(source: str | Any) -> str:
477476
Convert a GraphQL Source object or string into a plain string.
478477
"""
479478
if hasattr(source, "body"):
480-
body_value = getattr(source, "body")
479+
body_value = source.body
481480
if callable(body_value):
482481
body_value = body_value()
483482
return str(body_value)

graphql_sqlcommenter/middleware.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
import json
1313
import logging
1414
from collections.abc import Mapping, Sequence
15-
from typing import Callable, cast
15+
from typing import TYPE_CHECKING, Callable, cast
1616

1717
from django.conf import settings
18-
from django.http import HttpRequest, HttpResponse
1918

2019
from .context import GraphQLOperationType, set_graphql_meta
2120

21+
if TYPE_CHECKING:
22+
from django.http import HttpRequest, HttpResponse
23+
2224
logger = logging.getLogger(__name__)
2325

2426

@@ -149,4 +151,4 @@ def _parse_body(self, body: str) -> Mapping[str, object]:
149151
data = json.loads(body)
150152
if not isinstance(data, Mapping):
151153
raise ValueError("GraphQL request payload must be a JSON object")
152-
return cast(Mapping[str, object], data)
154+
return cast("Mapping[str, object]", data)

graphql_sqlcommenter/wrapper.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@
88
from __future__ import annotations
99

1010
import logging
11-
from typing import Any, Optional, Protocol
11+
from typing import TYPE_CHECKING, Any, Protocol
1212

1313
import django
1414
from django.db.backends.utils import CursorDebugWrapper
15-
from django.http import HttpRequest
16-
from django.urls import ResolverMatch
1715
from google.cloud.sqlcommenter import generate_sql_comment
1816
from google.cloud.sqlcommenter.opencensus import get_opencensus_values
1917
from google.cloud.sqlcommenter.opentelemetry import get_opentelemetry_values
2018

2119
from .context import GraphQLMetadata, get_graphql_meta
2220

21+
if TYPE_CHECKING:
22+
from django.http import HttpRequest
23+
from django.urls import ResolverMatch
24+
2325
django_version = django.get_version()
2426
logger = logging.getLogger(__name__)
2527

@@ -30,7 +32,7 @@ class ExecuteCallable(Protocol):
3032
def __call__(
3133
self,
3234
sql: str,
33-
params: Optional[tuple[Any, ...]],
35+
params: tuple[Any, ...] | None,
3436
many: bool,
3537
context: dict[str, Any],
3638
) -> Any: ...
@@ -57,7 +59,7 @@ def __call__(
5759
self,
5860
execute: ExecuteCallable,
5961
sql: str,
60-
params: Optional[tuple[Any, ...]],
62+
params: tuple[Any, ...] | None,
6163
many: bool,
6264
context: dict[str, Any],
6365
) -> Any:
@@ -98,14 +100,15 @@ def __call__(
98100

99101
if with_opencensus and with_opentelemetry:
100102
logger.warning(
101-
"SQLCOMMENTER_WITH_OPENCENSUS and SQLCOMMENTER_WITH_OPENTELEMETRY were enabled. "
103+
"SQLCOMMENTER_WITH_OPENCENSUS and "
104+
"SQLCOMMENTER_WITH_OPENTELEMETRY were enabled. "
102105
"Only use one to avoid unexpected behavior"
103106
)
104107

105108
# Extract Django request context
106109
connection_settings = context["connection"].settings_dict
107110
db_driver = str(connection_settings.get("ENGINE", ""))
108-
resolver_match: Optional[ResolverMatch] = getattr(
111+
resolver_match: ResolverMatch | None = getattr(
109112
self.request, "resolver_match", None
110113
)
111114

@@ -142,7 +145,7 @@ def __call__(
142145

143146
# Add GraphQL metadata if available
144147
if with_graphql:
145-
meta: Optional[GraphQLMetadata] = get_graphql_meta()
148+
meta: GraphQLMetadata | None = get_graphql_meta()
146149
if meta:
147150
comment_kwargs.update(
148151
{

0 commit comments

Comments
 (0)