diff --git a/ariadne/auth/keycloak.py b/ariadne/auth/keycloak.py index 173d08a..9b83225 100644 --- a/ariadne/auth/keycloak.py +++ b/ariadne/auth/keycloak.py @@ -57,12 +57,18 @@ class KeycloakOIDC: def _decode_claims(self, token: str, key: dict[str, Any]) -> dict[str, Any]: return jwt.decode( token, - key=jwt.algorithms.RSAAlgorithm.from_jwk(key), + key=self._key_from_jwk(key), algorithms=["RS256"], options={"verify_aud": False}, issuer=self._issuer, ) + def _key_from_jwk(self, key: dict[str, Any]) -> Any: + algorithm = getattr(jwt.algorithms, "RSAAlgorithm", None) + if algorithm and hasattr(algorithm, "from_jwk"): + return algorithm.from_jwk(key) + return jwt.PyJWK.from_dict(key).key + def _validate_audience(self, claims: dict[str, Any]) -> None: azp = claims.get("azp") aud = claims.get("aud") diff --git a/scripts/check_docstrings.py b/scripts/check_docstrings.py index eeea429..3229fa3 100644 --- a/scripts/check_docstrings.py +++ b/scripts/check_docstrings.py @@ -8,29 +8,45 @@ import ast from pathlib import Path +def _is_dataclass_class(node: ast.ClassDef) -> bool: + """Return whether a class uses the dataclass decorator.""" + + return any( + (isinstance(dec, ast.Name) and dec.id == "dataclass") + or (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == "dataclass") + for dec in node.decorator_list + ) + + +def _base_names(node: ast.ClassDef) -> set[str]: + """Return simple base class names used by a class definition.""" + + return {base.id for base in node.bases if isinstance(base, ast.Name)} + + +def _needs_function_docstring(node: ast.FunctionDef | ast.AsyncFunctionDef, parent_class: str | None) -> bool: + """Return whether a public function-like node needs a docstring.""" + + if node.name.startswith("_") and node.name != "__init__": + return False + return not (parent_class and node.name.startswith("_")) + + +def _needs_class_docstring(node: ast.ClassDef) -> bool: + """Return whether a public class-like node needs a docstring.""" + + bases = _base_names(node) + skipped_bases = {"Exception", "RuntimeError", "BaseException", "BaseModel"} + return not (node.name.startswith("_") or _is_dataclass_class(node) or bool(bases.intersection(skipped_bases))) + + def _needs_docstring(node: ast.AST, *, parent_class: str | None = None) -> bool: """Return whether `node` should carry an API contract docstring.""" if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - name = node.name - if name.startswith("_") and name != "__init__": - return False - return not (parent_class and name.startswith("_")) + return _needs_function_docstring(node, parent_class) if isinstance(node, ast.ClassDef): - if node.name.startswith("_"): - return False - if any( - (isinstance(dec, ast.Name) and dec.id == "dataclass") - or (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == "dataclass") - for dec in node.decorator_list - ): - return False - if any( - isinstance(base, ast.Name) and base.id in {"Exception", "RuntimeError", "BaseException"} - for base in node.bases - ): - return False - return not any(isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases) + return _needs_class_docstring(node) return False diff --git a/scripts/publish_test_metrics.py b/scripts/publish_test_metrics.py index 2bb85be..c6d1b8f 100644 --- a/scripts/publish_test_metrics.py +++ b/scripts/publish_test_metrics.py @@ -204,11 +204,31 @@ def _supply_chain_check_status(build_dir: Path) -> str: return "failed" +def _resolve_artifact_paths(repo_root: Path) -> tuple[Path, Path]: + """Find coverage and JUnit artifacts even when a test runner uses fallback names.""" + + coverage_path = Path(os.getenv("COVERAGE_JSON", "build/coverage.json")) + junit_path = Path(os.getenv("JUNIT_XML", "build/junit.xml")) + if not coverage_path.exists(): + for candidate in ( + repo_root / "build" / "coverage.json", + repo_root / "build" / "coverage-summary.json", + repo_root / "build" / "coverage" / "coverage-summary.json", + ): + if candidate.exists(): + coverage_path = candidate + break + if not junit_path.exists(): + junit_candidates = sorted((repo_root / "build").glob("junit*.xml")) + if junit_candidates: + junit_path = junit_candidates[0] + return coverage_path, junit_path + + def main() -> int: repo_root = Path(__file__).resolve().parents[1] build_dir = repo_root / "build" - coverage_path = os.getenv("COVERAGE_JSON", "build/coverage.json") - junit_path = os.getenv("JUNIT_XML", "build/junit.xml") + coverage_path, junit_path = _resolve_artifact_paths(repo_root) pushgateway_url = os.getenv( "PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091" ).strip() @@ -217,16 +237,19 @@ def main() -> int: build_number = os.getenv("BUILD_NUMBER", "") commit = os.getenv("GIT_COMMIT", "") + print(f"[metrics] coverage_path={coverage_path} exists={coverage_path.exists()}") + print(f"[metrics] junit_path={junit_path} exists={junit_path.exists()}") + coverage = 0.0 - if os.path.exists(coverage_path): - coverage = _load_coverage(coverage_path) + if coverage_path.exists(): + coverage = _load_coverage(str(coverage_path)) docs_gate_rc = _load_gate_rc(Path(os.getenv("QUALITY_GATE_DOCS_RC_PATH", str(build_dir / "docs-naming.rc")))) source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500) totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} test_cases: list[tuple[str, str]] = [] - if os.path.exists(junit_path): - totals = _load_junit(junit_path) - test_cases = _load_junit_cases(junit_path) + if junit_path.exists(): + totals = _load_junit(str(junit_path)) + test_cases = _load_junit_cases(str(junit_path)) passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) outcome = "ok" @@ -284,10 +307,15 @@ def main() -> int: "# TYPE ariadne_quality_gate_build_info gauge", f"ariadne_quality_gate_build_info{_label_str(labels)} 1", ] - payload_lines.extend( - f'platform_quality_gate_test_case_result{{suite="{suite}",test="{_escape_label(test_name)}",status="{_escape_label(test_status)}"}} 1' - for test_name, test_status in test_cases - ) + if test_cases: + payload_lines.extend( + f'platform_quality_gate_test_case_result{{suite="{suite}",test="{_escape_label(test_name)}",status="{_escape_label(test_status)}"}} 1' + for test_name, test_status in test_cases + ) + else: + payload_lines.append( + f'platform_quality_gate_test_case_result{{suite="{suite}",test="__no_test_cases__",status="skipped"}} 1' + ) payload_lines.extend( f'ariadne_quality_gate_checks_total{{suite="{suite}",check="{check_name}",result="{check_status}"}} 1' for check_name, check_status in checks.items() diff --git a/tests/test_auth.py b/tests/test_auth.py index 32f71e2..0472328 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -20,7 +20,7 @@ def test_keycloak_verify_accepts_matching_audience(monkeypatch) -> None: kc = KeycloakOIDC("https://jwks", "https://issuer", "portal") monkeypatch.setattr(kc, "_get_jwks", lambda force=False: {"keys": [{"kid": "test"}]}) - monkeypatch.setattr(jwt.algorithms.RSAAlgorithm, "from_jwk", lambda key: "dummy") + monkeypatch.setattr(kc, "_key_from_jwk", lambda key: "dummy") monkeypatch.setattr( jwt, "decode", @@ -36,7 +36,7 @@ def test_keycloak_verify_rejects_wrong_audience(monkeypatch) -> None: kc = KeycloakOIDC("https://jwks", "https://issuer", "portal") monkeypatch.setattr(kc, "_get_jwks", lambda force=False: {"keys": [{"kid": "test"}]}) - monkeypatch.setattr(jwt.algorithms.RSAAlgorithm, "from_jwk", lambda key: "dummy") + monkeypatch.setattr(kc, "_key_from_jwk", lambda key: "dummy") monkeypatch.setattr( jwt, "decode", @@ -73,7 +73,7 @@ def test_keycloak_verify_refreshes_jwks(monkeypatch) -> None: return {"keys": [{"kid": "test"}]} monkeypatch.setattr(kc, "_get_jwks", fake_get_jwks) - monkeypatch.setattr(jwt.algorithms.RSAAlgorithm, "from_jwk", lambda key: "dummy") + monkeypatch.setattr(kc, "_key_from_jwk", lambda key: "dummy") monkeypatch.setattr( jwt, "decode",