-
Notifications
You must be signed in to change notification settings - Fork 0
fix: validate log containers before generated normalization #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Validate log response containers before generated model normalization.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import TYPE_CHECKING, cast | ||
|
|
||
| if TYPE_CHECKING: | ||
| from .models import JSONValue | ||
|
|
||
| INVALID_LOG_RESPONSE = "Expected a complete log response" | ||
|
|
||
|
|
||
| def response_values(payload: object) -> Mapping[str, object]: | ||
| """Require a JSON object for the response envelope. | ||
|
|
||
| Raises: | ||
| TypeError: The response envelope is not an object. | ||
|
|
||
| Returns: | ||
| The response fields without normalizing their values. | ||
|
|
||
| """ | ||
| if not isinstance(payload, Mapping): | ||
| raise TypeError(INVALID_LOG_RESPONSE) | ||
| return cast("Mapping[str, object]", payload) | ||
|
|
||
|
|
||
| def response_data(values: Mapping[str, object]) -> tuple[Mapping[str, JSONValue], ...]: | ||
| """Require a list of objects without coercing empty mappings into lists. | ||
|
|
||
| Raises: | ||
| TypeError: The rows are not a list of objects. | ||
|
|
||
| Returns: | ||
| The validated rows in their original order. | ||
|
|
||
| """ | ||
| raw_data = values.get("data") | ||
| if not isinstance(raw_data, list): | ||
| raise TypeError(INVALID_LOG_RESPONSE) | ||
| data = cast("list[object]", raw_data) | ||
| if any(not isinstance(item, Mapping) for item in data): | ||
| raise TypeError(INVALID_LOG_RESPONSE) | ||
| return tuple(cast("Mapping[str, JSONValue]", item) for item in data) | ||
|
|
||
|
|
||
| def search_metadata(values: Mapping[str, object]) -> tuple[int, bool, str | None]: | ||
| """Require search pagination fields without coercing their values. | ||
|
|
||
| Raises: | ||
| TypeError: Required metadata is missing or has an invalid type. | ||
|
|
||
| Returns: | ||
| The page limit, continuation flag, and optional cursor. | ||
|
|
||
| """ | ||
| limit = values.get("limit") | ||
| has_more = values.get("has_more") | ||
| next_cursor = values.get("next_cursor") | ||
| if ( | ||
| not isinstance(limit, int) | ||
| or isinstance(limit, bool) | ||
| or not isinstance(has_more, bool) | ||
| or (next_cursor is not None and not isinstance(next_cursor, str)) | ||
| ): | ||
| raise TypeError(INVALID_LOG_RESPONSE) | ||
| return limit, has_more, next_cursor | ||
|
|
||
|
|
||
| def activity_total(values: Mapping[str, object]) -> int: | ||
| """Require the activity total without treating booleans as integers. | ||
|
|
||
| Raises: | ||
| TypeError: The total is missing or is not an integer. | ||
|
|
||
| Returns: | ||
| The total reported by the server. | ||
|
|
||
| """ | ||
| total = values.get("total") | ||
| if not isinstance(total, int) or isinstance(total, bool): | ||
| raise TypeError(INVALID_LOG_RESPONSE) | ||
| return total |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| import httpx | ||
| import pytest | ||
| from test_logs import FakeLogsTransport, FakeResponse, logs_client | ||
| from test_logs_refresh import make_client | ||
|
|
||
| if TYPE_CHECKING: | ||
| from volcano_sdk import VolcanoClient | ||
|
|
||
| PROJECT_ID = "00000000-0000-4000-8000-000000000001" | ||
| REQUEST = {"resource": {"type": "function"}} | ||
|
|
||
|
|
||
| def response_client(payload: object, *, native_transport: bool) -> VolcanoClient: | ||
| def handle(_request: httpx.Request) -> httpx.Response: | ||
| return httpx.Response(200, json=payload) | ||
|
|
||
| if native_transport: | ||
| return make_client(handle) | ||
| transport = FakeLogsTransport() | ||
| transport.search_response = FakeResponse(200, payload, {}) | ||
| transport.activity_response = FakeResponse(200, payload, {}) | ||
| return logs_client(transport) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| @pytest.mark.parametrize("operation", ["search", "activity"]) | ||
| @pytest.mark.parametrize("payload", [None, [], "invalid", 1, True]) | ||
| def test_logs_reject_non_object_responses( | ||
| operation: str, payload: object, *, native_transport: bool | ||
| ) -> None: | ||
| logs = response_client(payload, native_transport=native_transport).logs | ||
| read = logs.search if operation == "search" else logs.activity | ||
|
|
||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| read(PROJECT_ID, REQUEST) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| @pytest.mark.parametrize("operation", ["search", "activity"]) | ||
| @pytest.mark.parametrize("data", [None, {}, "invalid", [None], [1], [[], {}]]) | ||
| def test_logs_reject_invalid_response_rows( | ||
| operation: str, data: object, *, native_transport: bool | ||
| ) -> None: | ||
| payload = {"data": data, "limit": 25, "has_more": False, "total": 0} | ||
| logs = response_client(payload, native_transport=native_transport).logs | ||
| read = logs.search if operation == "search" else logs.activity | ||
|
|
||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| read(PROJECT_ID, REQUEST) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("field", "value"), | ||
| [ | ||
| ("limit", None), | ||
| ("limit", "25"), | ||
| ("limit", 25.0), | ||
| ("limit", True), | ||
| ("has_more", None), | ||
| ("has_more", "false"), | ||
| ("has_more", 0), | ||
| ("next_cursor", 1), | ||
| ("next_cursor", []), | ||
| ], | ||
| ) | ||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| def test_logs_search_rejects_invalid_page_metadata( | ||
| field: str, value: object, *, native_transport: bool | ||
| ) -> None: | ||
| payload: dict[str, object] = {"data": [], "limit": 25, "has_more": False} | ||
| payload[field] = value | ||
| client = response_client(payload, native_transport=native_transport) | ||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| client.logs.search(PROJECT_ID, REQUEST) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| @pytest.mark.parametrize("total", [None, "0", 0.0, True]) | ||
| def test_logs_activity_rejects_invalid_totals( | ||
| total: object, *, native_transport: bool | ||
| ) -> None: | ||
| client = response_client( | ||
| {"data": [], "total": total}, native_transport=native_transport | ||
| ) | ||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| client.logs.activity(PROJECT_ID, REQUEST) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| @pytest.mark.parametrize("field", ["data", "limit", "has_more"]) | ||
| def test_logs_search_rejects_missing_required_fields( | ||
| field: str, *, native_transport: bool | ||
| ) -> None: | ||
| payload: dict[str, object] = {"data": [], "limit": 25, "has_more": False} | ||
| del payload[field] | ||
| client = response_client(payload, native_transport=native_transport) | ||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| client.logs.search(PROJECT_ID, REQUEST) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("native_transport", [False, True]) | ||
| @pytest.mark.parametrize("field", ["data", "total"]) | ||
| def test_logs_activity_rejects_missing_required_fields( | ||
| field: str, *, native_transport: bool | ||
| ) -> None: | ||
| payload: dict[str, object] = {"data": [], "total": 0} | ||
| del payload[field] | ||
| client = response_client(payload, native_transport=native_transport) | ||
| with pytest.raises(TypeError, match="Expected a complete log response"): | ||
| client.logs.activity(PROJECT_ID, REQUEST) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For an actual 200 response whose JSON body is
[]or"invalid", the generated model'sfrom_dictraisesKeyErrororValueErrorbefore_response_valuesruns, rather than the assertedTypeErrorwithExpected a complete log response. Injecting these values throughFakeLogsTransportbypasses that parser and makes the test claim a consistent failure mode that real SDK users do not receive; exercise these cases withmake_clientand normalize the generated parse failures.Useful? React with 👍 / 👎.