mirror of
https://github.com/apache/superset.git
synced 2026-08-04 13:02:41 +00:00
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
import io
|
|
from unittest import mock
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
import pytest
|
|
|
|
from scripts import change_detector
|
|
|
|
|
|
def _make_response(body: bytes) -> mock.MagicMock:
|
|
"""Builds a mock that mimics urlopen's context-manager response."""
|
|
response: mock.MagicMock = mock.MagicMock()
|
|
response.__enter__.return_value.read.return_value = body
|
|
return response
|
|
|
|
|
|
def test_fetch_retries_transient_error_then_succeeds() -> None:
|
|
payload: list[dict[str, str]] = [{"filename": "superset/foo.py"}]
|
|
side_effects: list[object] = [
|
|
HTTPError("http://api", 500, "Server Error", {}, None), # type: ignore
|
|
HTTPError("http://api", 502, "Bad Gateway", {}, None), # type: ignore
|
|
_make_response(b'[{"filename": "superset/foo.py"}]'),
|
|
]
|
|
with (
|
|
mock.patch.object(
|
|
change_detector, "urlopen", side_effect=side_effects
|
|
) as urlopen_mock,
|
|
mock.patch.object(change_detector.time, "sleep") as sleep_mock,
|
|
):
|
|
result = change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert result == payload
|
|
assert urlopen_mock.call_count == 3
|
|
assert sleep_mock.call_count == 2
|
|
|
|
|
|
def test_fetch_retries_rate_limit_then_succeeds() -> None:
|
|
side_effects: list[object] = [
|
|
HTTPError("http://api", 429, "Too Many Requests", {}, None), # type: ignore
|
|
_make_response(b"[]"),
|
|
]
|
|
with (
|
|
mock.patch.object(
|
|
change_detector, "urlopen", side_effect=side_effects
|
|
) as urlopen_mock,
|
|
mock.patch.object(change_detector.time, "sleep") as sleep_mock,
|
|
):
|
|
result = change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert result == []
|
|
assert urlopen_mock.call_count == 2
|
|
assert sleep_mock.call_count == 1
|
|
|
|
|
|
def test_fetch_does_not_retry_client_error() -> None:
|
|
with (
|
|
mock.patch.object(
|
|
change_detector,
|
|
"urlopen",
|
|
side_effect=HTTPError("http://api", 404, "Not Found", {}, None), # type: ignore
|
|
) as urlopen_mock,
|
|
mock.patch.object(change_detector.time, "sleep") as sleep_mock,
|
|
):
|
|
with pytest.raises(HTTPError):
|
|
change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert urlopen_mock.call_count == 1
|
|
assert sleep_mock.call_count == 0
|
|
|
|
|
|
def test_fetch_gives_up_after_max_retries() -> None:
|
|
with (
|
|
mock.patch.object(
|
|
change_detector, "urlopen", side_effect=URLError("connection reset")
|
|
) as urlopen_mock,
|
|
mock.patch.object(change_detector.time, "sleep"),
|
|
):
|
|
with pytest.raises(URLError):
|
|
change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert urlopen_mock.call_count == change_detector.MAX_RETRIES
|
|
|
|
|
|
def _http_error(
|
|
status: int, headers: dict[str, str] | None = None, body: bytes = b""
|
|
) -> HTTPError:
|
|
"""Builds an HTTPError carrying headers and a readable body, as urllib does."""
|
|
return HTTPError(
|
|
"http://api",
|
|
status,
|
|
"Forbidden",
|
|
headers or {}, # type: ignore[arg-type]
|
|
io.BytesIO(body),
|
|
)
|
|
|
|
|
|
def test_fetch_does_not_retry_a_permission_denied_403() -> None:
|
|
"""A missing token scope is deterministic; retrying only delays the failure."""
|
|
error = _http_error(
|
|
403,
|
|
headers={"x-ratelimit-remaining": "4998"},
|
|
body=b'{"message": "Resource not accessible by integration"}',
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
change_detector, "urlopen", side_effect=error
|
|
) as urlopen_mock,
|
|
mock.patch.object(change_detector.time, "sleep") as sleep_mock,
|
|
):
|
|
with pytest.raises(HTTPError):
|
|
change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert urlopen_mock.call_count == 1
|
|
sleep_mock.assert_not_called()
|
|
|
|
|
|
def test_fetch_retries_a_rate_limited_403() -> None:
|
|
"""GitHub signals rate limiting with 403 and an exhausted remaining count."""
|
|
side_effects: list[object] = [
|
|
_http_error(403, headers={"x-ratelimit-remaining": "0"}),
|
|
_make_response(b'[{"filename": "superset/foo.py"}]'),
|
|
]
|
|
with (
|
|
mock.patch.object(change_detector, "urlopen", side_effect=side_effects),
|
|
mock.patch.object(change_detector.time, "sleep"),
|
|
):
|
|
result = change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert result == [{"filename": "superset/foo.py"}]
|
|
|
|
|
|
def test_fetch_reports_the_api_error_message(capsys) -> None:
|
|
"""The response body names the cause; without it a 403 is unactionable."""
|
|
error = _http_error(
|
|
403,
|
|
headers={"x-ratelimit-remaining": "4998"},
|
|
body=b'{"message": "Resource not accessible by integration"}',
|
|
)
|
|
with mock.patch.object(change_detector, "urlopen", side_effect=error):
|
|
with pytest.raises(HTTPError):
|
|
change_detector.fetch_files_github_api("http://api")
|
|
|
|
assert "Resource not accessible by integration" in capsys.readouterr().out
|
|
|
|
|
|
def test_image_tag_compose_changes_trigger_python_tests() -> None:
|
|
assert change_detector.detect_changes(
|
|
["docker-compose-image-tag.yml"],
|
|
change_detector.PATTERNS["python"],
|
|
)
|