Files
LEDMatrix/src/web_interface/api_helpers.py
T
ChuckandClaude Opus 5.5 b8c01c69fb ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)
* ci: mypy ratchet -- keep type-clean modules clean

mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).

35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.

mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: annotate check_types.py's list-form mypy subprocess

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:01:35 -04:00

153 lines
4.5 KiB
Python

"""
Standardized API response helpers.
Provides consistent API response formatting across all endpoints.
"""
import time
from typing import Any, Optional, Dict, Tuple
from flask import jsonify, request
from src.web_interface.error_handler import create_error_response, create_success_response
from src.web_interface.errors import ErrorCode, WebInterfaceError
def success_response(
data: Any = None,
message: Optional[str] = None,
metadata: Optional[Dict] = None
):
"""
Create a standardized success response.
Args:
data: Response data
message: Optional success message
metadata: Optional metadata (timing, version, etc.)
Returns:
Flask jsonify response
"""
response_data = create_success_response(data, message, metadata)
# Timing is merged into whatever the caller passed, without inventing a
# metadata block for responses that have neither.
enriched = dict(metadata) if metadata is not None else {}
if hasattr(request, 'start_time'):
# request_logging stamps start_time from perf_counter, not the wall clock.
enriched['response_time_ms'] = int((time.perf_counter() - request.start_time) * 1000)
if metadata is not None or enriched:
response_data['metadata'] = enriched
return jsonify(response_data)
def error_response(
error_code: ErrorCode,
message: str,
details: Optional[str] = None,
context: Optional[Dict] = None,
suggested_fixes: Optional[list] = None,
status_code: int = 500
):
"""
Create a standardized error response.
Args:
error_code: Error code
message: Error message
details: Optional detailed error information
context: Optional context dictionary
suggested_fixes: Optional list of suggested fixes
status_code: HTTP status code
Returns:
Flask jsonify response with status code
"""
return create_error_response(
error_code=error_code,
message=message,
details=details,
context=context,
suggested_fixes=suggested_fixes,
status_code=status_code
)
def exception_error_response(
exc: Exception,
error_code: ErrorCode,
*,
with_context: bool = True,
status_code: int = 500
):
"""
error_response() for a caught exception, built by WebInterfaceError.
The message is the code's fixed, user-facing one -- never the exception
text. `details` comes from the exception's own `context` dict when it has
one, and `context` records the exception type. with_context=False leaves
the context out, as the operation-history routes always have.
Args:
exc: The exception being reported
error_code: Error code
with_context: Whether to include the context (exception type)
status_code: HTTP status code
Returns:
Flask jsonify response with status code
"""
error = WebInterfaceError.from_exception(exc, error_code)
return error_response(
error.error_code,
error.message,
details=error.details,
context=error.context if with_context else None,
status_code=status_code
)
def validate_request_json(required_fields: list, data: Any = None) -> Tuple[Optional[Dict], Optional[Any]]:
"""
Validate request JSON has required fields.
Args:
required_fields: List of required field names
data: Optional data dict (if None, reads from request)
Returns:
Tuple of (data_dict, error_response) or (data_dict, None) if valid
"""
if data is None:
data = request.get_json(silent=True)
if not data:
return None, error_response(
ErrorCode.INVALID_INPUT,
"Request body must be valid JSON",
status_code=400
)
# A JSON array passes the check above, and ``field in data`` then tests
# list membership: ["plugin_id"] "had" every required field and the
# handler's data['plugin_id'] raised TypeError -- a 500, not a 400.
if not isinstance(data, dict):
return None, error_response(
ErrorCode.INVALID_INPUT,
"Request body must be a JSON object",
status_code=400
)
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return None, error_response(
ErrorCode.INVALID_INPUT,
f"Missing required fields: {', '.join(missing_fields)}",
context={'missing_fields': missing_fields},
status_code=400
)
return data, None