Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class ScopeBindingSettings(Protocol):


class _Response(Protocol):
fp: object
status: int

def __enter__(self) -> _Response: ...
Expand Down Expand Up @@ -203,10 +204,12 @@ def _request_json(
method=method,
)
try:
with _URL_OPENER.open(request, timeout=min(settings.request_timeout_seconds, remaining)) as response:
request_timeout = min(settings.request_timeout_seconds, remaining)
request_deadline = min(deadline, monotonic() + request_timeout)
with _URL_OPENER.open(request, timeout=request_timeout) as response:
if response.status < 200 or response.status >= 300:
raise ScopeBindingError
raw = _read_bounded(response)
raw = _read_bounded(response, deadline=request_deadline)
except (HTTPError, OSError, TimeoutError) as error:
raise ScopeBindingError from error
try:
Expand All @@ -218,15 +221,33 @@ def _request_json(
return value


def _read_bounded(response: _Response) -> bytes:
def _read_bounded(response: _Response, *, deadline: float) -> bytes:
chunks: list[bytes] = []
size = 0
while chunk := response.read(_READ_CHUNK_BYTES):
while True:
_set_response_timeout(response, _remaining_time(deadline))
chunk = response.read(_READ_CHUNK_BYTES)
if not chunk:
return b"".join(chunks)
size += len(chunk)
if size > _MAX_RESPONSE_BYTES:
raise ScopeBindingError
chunks.append(chunk)
return b"".join(chunks)


def _remaining_time(deadline: float) -> float:
remaining = deadline - monotonic()
if remaining <= 0:
raise TimeoutError
return remaining


def _set_response_timeout(response: _Response, timeout: float) -> None:
raw = getattr(response.fp, "raw", None)
sock = getattr(raw, "_sock", None)
settimeout = getattr(sock, "settimeout", None)
if settimeout is not None:
settimeout(timeout)


def _git_value(cwd: str, *arguments: str) -> str | None:
Expand Down
40 changes: 40 additions & 0 deletions tests/workbuddy_plugin/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (c) 2026 OceanBase.
#
# Licensed 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.

from __future__ import annotations

import importlib.util
from pathlib import Path
from types import ModuleType

import pytest

REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
WORKBUDDY_ROOT = REPOSITORY_ROOT / "integrations" / "workbuddy"
PLUGIN_ROOT = WORKBUDDY_ROOT / "plugins" / "powercontext"


def _load_module(name: str, path: Path) -> ModuleType:
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


@pytest.fixture
def scope_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
monkeypatch.syspath_prepend(str(PLUGIN_ROOT / "hooks"))
monkeypatch.syspath_prepend(str(PLUGIN_ROOT))
return _load_module("powercontext_workbuddy_scope", PLUGIN_ROOT / "scripts" / "workspace_scope.py")
88 changes: 88 additions & 0 deletions tests/workbuddy_plugin/test_scope_binding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Copyright (c) 2026 OceanBase.
#
# Licensed 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.

from __future__ import annotations

from types import ModuleType

import pytest


class _Socket:
def __init__(self) -> None:
self.timeouts: list[float] = []

def settimeout(self, value: float) -> None:
self.timeouts.append(value)


class _Raw:
def __init__(self, sock: _Socket) -> None:
self._sock = sock


class _FilePointer:
def __init__(self, sock: _Socket) -> None:
self.raw = _Raw(sock)


class _SlowResponse:
status = 200

def __init__(self) -> None:
self.sock = _Socket()
self.fp = _FilePointer(self.sock)

def __enter__(self) -> _SlowResponse:
return self

def __exit__(self, *args: object) -> object:
return None

def read(self, amount: int = -1) -> bytes:
assert self.sock.timeouts
raise TimeoutError
Comment on lines +53 to +55


class _Opener:
def __init__(self, response: _SlowResponse) -> None:
self.response = response

def open(self, request: object, *, timeout: float) -> _SlowResponse:
return self.response


class _Settings:
server_url = "http://127.0.0.1:8000"
authorization = None
scope_id = None
request_timeout_seconds = 3.0


def test_scope_binding_bounds_response_body_reads(
scope_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = _SlowResponse()
monkeypatch.setattr(scope_module, "_URL_OPENER", _Opener(response))

with pytest.raises(scope_module.ScopeBindingError):
scope_module._request_json(
"/v1/scope-bindings/resolve",
{"binding_keys": []},
settings=_Settings(),
deadline=scope_module.monotonic() + 6.0,
)

assert response.sock.timeouts
Loading