Skip to content
Open
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
19 changes: 14 additions & 5 deletions api/dev/lint_response_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing
response schemas, and returns with non-literal status codes are classified as
unknown so reviewers can triage them without blocking unrelated work. The one
intentional non-schema mismatch is a known body/schema on a no-body status such
as 204, 205, or 304.
unknown. Unknown details are hidden by default to keep routine output focused;
pass ``--include-unknown`` when triaging them. The one intentional non-schema
mismatch is a known body/schema on a no-body status such as 204, 205, or 304.
"""

from __future__ import annotations
Expand Down Expand Up @@ -589,7 +589,7 @@ def as_jsonable(check: ContractCheck) -> dict[str, Any]:
return data


def print_text_report(checks: Sequence[ContractCheck], *, include_valid: bool) -> None:
def print_text_report(checks: Sequence[ContractCheck], *, include_unknown: bool, include_valid: bool) -> None:
counts = Counter(check.classification for check in checks)
sys.stdout.write(
"Response contract lint: "
Expand All @@ -601,6 +601,8 @@ def print_text_report(checks: Sequence[ContractCheck], *, include_valid: bool) -

for classification in ("mismatch", "refactorable", "unknown", "valid"):
filtered = [check for check in checks if check.classification == classification]
if classification == "unknown" and not include_unknown:
continue
if classification == "valid" and not include_valid:
continue
if not filtered:
Expand All @@ -619,6 +621,7 @@ def parse_args() -> argparse.Namespace:
nargs="*",
help="Files or directories to lint. Defaults to Flask controller directories.",
)
parser.add_argument("--include-unknown", action="store_true", help="Print unknown route methods in output.")
parser.add_argument("--include-valid", action="store_true", help="Print valid route methods in text output.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
parser.add_argument(
Expand Down Expand Up @@ -650,10 +653,16 @@ def main() -> int:
if args.json:
grouped = defaultdict(list)
for check in checks:
if check.classification == "unknown" and not args.include_unknown:
continue
grouped[check.classification].append(as_jsonable(check))
sys.stdout.write(f"{json.dumps(grouped, indent=2, sort_keys=True)}\n")
else:
print_text_report(checks, include_valid=bool(args.include_valid))
print_text_report(
checks,
include_unknown=bool(args.include_unknown),
include_valid=bool(args.include_valid),
)

has_mismatch = any(check.classification == "mismatch" for check in checks)
has_unknown = any(check.classification == "unknown" for check in checks)
Expand Down
32 changes: 31 additions & 1 deletion api/tests/unit_tests/commands/test_lint_response_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import sys
from pathlib import Path

import pytest


def _load_lint_response_contracts_module():
api_dir = Path(__file__).parents[3]
Expand Down Expand Up @@ -115,7 +117,7 @@ def generate_events():
assert {actual.model for actual in checks[0].actual} == {"StreamResponse"}


def test_main_is_report_only_by_default_for_mismatches(tmp_path: Path, monkeypatch):
def test_main_is_report_only_by_default_for_mismatches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
module = _load_lint_response_contracts_module()
controller_path = tmp_path / "controllers" / "sample.py"
controller_path.parent.mkdir()
Expand All @@ -137,6 +139,34 @@ def delete(self):
assert module.main() == 1


def test_main_hides_unknown_details_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys):
module = _load_lint_response_contracts_module()
controller_path = tmp_path / "controllers" / "sample.py"
controller_path.parent.mkdir()
controller_path.write_text(
"""
@ns.route("/items")
class ItemApi(Resource):
@ns.response(200, "OK", ns.models[ItemResponse.__name__])
def get(self):
return dump_response(ItemResponse, item), status_code
""",
encoding="utf-8",
)

monkeypatch.setattr(sys, "argv", ["lint_response_contracts.py", str(controller_path)])
assert module.main() == 0
default_output = capsys.readouterr().out
assert "1 unknown" in default_output
assert "UNKNOWN:" not in default_output

monkeypatch.setattr(sys, "argv", ["lint_response_contracts.py", "--include-unknown", str(controller_path)])
assert module.main() == 0
include_unknown_output = capsys.readouterr().out
assert "UNKNOWN:" in include_unknown_output
assert "non-literal or unsupported status" in include_unknown_output


def test_class_level_route_and_response_docs_apply_to_methods(tmp_path: Path):
checks = _checks_for_source(
tmp_path,
Expand Down
Loading