forked from HAL9000/cleveragents-core
051ee7c290
Added 52 new .feature files and corresponding _steps.py files targeting previously uncovered code paths in the following areas: - TUI layer: app, commands, persona (state/schema/registry), widgets, input (shell_exec, reference_parser) - Application services: plan lifecycle/service/executor, session, project, repo indexing, correction, checkpoint, actor, llm_actors, strategy coordinator, resource file watcher, service retry wiring - CLI commands: session, resource, repl, plan, db, automation_profile - Domain models: retry_policy, resource_type, cost_budget, docker_compose_analyzer, detail_level, _sql_string_aware, _postgresql_helpers - Core: circuit_breaker, retry_service_patterns - Infrastructure: repositories, transaction_sandbox, strategy_registry, plugins/loader, container - Config: settings - Agents: plan_generation, context_analysis, auto_debug - A2A: facade All new tests follow the Behave/Gherkin BDD standard. Resolved step definition collisions with unique prefixes. Fixed Alembic fileConfig logger disabling issue (disable_existing_loggers=False). ISSUES CLOSED: #1068
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""Step definitions for retry_service_patterns_coverage.feature.
|
|
|
|
Targets uncovered lines in retry_service_patterns.py:
|
|
- Line 82: _is_async_callable partial(async coroutine) → True
|
|
(unreachable on Python 3.13 where iscoroutinefunction handles
|
|
partials natively; covered indirectly via partial(callable_obj))
|
|
- Line 85: _is_async_callable partial(callable_obj_with_async_call) → True
|
|
- Line 86: _is_async_callable partial(SomeClass) → False
|
|
- Line 90: _is_async_callable(non_callable) → False
|
|
- Line 254: async retry_service_operation CircuitBreakerOpen with no breaker
|
|
- Lines 444-446: RetryContext.execute rejects async callables
|
|
- Lines 452-453: RetryContext.execute nesting guard
|
|
- Lines 507-508: RetryContext.async_execute nesting guard
|
|
- Line 482: RetryContext.execute RuntimeError safeguard
|
|
- Line 537: RetryContext.async_execute RuntimeError safeguard
|
|
- Lines 575-577: retry_auto_debug rejects sync callables
|
|
- Line 639: retry_auto_debug returns None with zero attempts
|
|
"""
|
|
|
|
import asyncio
|
|
from functools import partial
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.core.retry_patterns import CircuitBreakerOpen
|
|
from cleveragents.core.retry_service_patterns import (
|
|
RetryContext,
|
|
_is_async_callable,
|
|
_retry_depth,
|
|
retry_auto_debug,
|
|
retry_service_operation,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_async(coro):
|
|
"""Run a coroutine in a fresh event loop."""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _is_async_callable detects partial wrapping async callable object
|
|
# (line 85 — partial wrapping a non-type callable with async __call__)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a functools.partial wrapping a callable object with async __call__")
|
|
def step_partial_wrapping_async_callable_obj(context):
|
|
class AsyncCallableObj:
|
|
async def __call__(self, x):
|
|
return x
|
|
|
|
context.partial_async_callable = partial(AsyncCallableObj(), 42)
|
|
|
|
|
|
@when("I check if the partial wrapping async callable is async")
|
|
def step_check_partial_async_callable(context):
|
|
context.is_async_callable_result = _is_async_callable(
|
|
context.partial_async_callable
|
|
)
|
|
|
|
|
|
@then("the async callable partial result should be True")
|
|
def step_assert_partial_async_callable_true(context):
|
|
assert context.is_async_callable_result is True, (
|
|
f"Expected True, got {context.is_async_callable_result}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _is_async_callable returns False for partial wrapping a class type
|
|
# (line 86)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a functools.partial wrapping a class type")
|
|
def step_partial_wrapping_class(context):
|
|
class MyClass:
|
|
def __init__(self, x=10):
|
|
self.x = x
|
|
|
|
context.partial_type = partial(MyClass, x=5)
|
|
|
|
|
|
@when("I check if the partial-wrapped type is async callable")
|
|
def step_check_partial_type_async(context):
|
|
context.is_async_result_type = _is_async_callable(context.partial_type)
|
|
|
|
|
|
@then("rspcov the result should be False for partial type detection")
|
|
def step_assert_partial_type_false(context):
|
|
assert context.is_async_result_type is False, (
|
|
f"Expected False, got {context.is_async_result_type}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _is_async_callable returns False for non-callable (line 90)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a plain integer value")
|
|
def step_plain_integer(context):
|
|
context.non_callable = 42
|
|
|
|
|
|
@when("I check if the integer is async callable")
|
|
def step_check_integer_async(context):
|
|
context.is_async_integer_result = _is_async_callable(context.non_callable)
|
|
|
|
|
|
@then("rspcov the result should be False for the non-callable value")
|
|
def step_assert_integer_false(context):
|
|
assert context.is_async_integer_result is False, (
|
|
f"Expected False, got {context.is_async_integer_result}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: Async retry_service_operation with CircuitBreakerOpen, no breaker
|
|
# (line 254)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
"I have an async function decorated with retry_service_operation"
|
|
" and no circuit breaker"
|
|
)
|
|
def step_async_decorated_no_breaker(context):
|
|
@retry_service_operation(
|
|
service_name="test-svc",
|
|
operation_name="test-op",
|
|
max_attempts=2,
|
|
base_delay=0.01,
|
|
max_delay=0.02,
|
|
circuit_breaker=None,
|
|
total_timeout=5.0,
|
|
)
|
|
async def _failing_op():
|
|
raise CircuitBreakerOpen("circuit is open")
|
|
|
|
context.async_cb_func = _failing_op
|
|
|
|
|
|
@given("the async function raises CircuitBreakerOpen")
|
|
def step_noop_marker(_context):
|
|
pass # Configured in the previous step
|
|
|
|
|
|
@when("I call the decorated async service operation")
|
|
def step_call_async_service_op(context):
|
|
context.cb_error = None
|
|
try:
|
|
_run_async(context.async_cb_func())
|
|
except CircuitBreakerOpen as exc:
|
|
context.cb_error = exc
|
|
|
|
|
|
@then("CircuitBreakerOpen should propagate with failure count zero")
|
|
def step_assert_cb_open_propagated(context):
|
|
assert context.cb_error is not None, "Expected CircuitBreakerOpen to be raised"
|
|
assert isinstance(context.cb_error, CircuitBreakerOpen)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: RetryContext.execute rejects async callables (lines 444-446)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('I have a RetryContext for operation "{name}"')
|
|
def step_create_retry_context(context, name):
|
|
context.retry_ctx = RetryContext(operation_name=name, max_attempts=2)
|
|
|
|
|
|
@given("I have an async callable to pass to sync execute")
|
|
def step_create_async_for_sync(context):
|
|
async def _async_fn():
|
|
return "async_result"
|
|
|
|
context.async_for_sync = _async_fn
|
|
|
|
|
|
@when("I call execute with the async callable")
|
|
def step_call_execute_with_async(context):
|
|
context.type_error = None
|
|
try:
|
|
context.retry_ctx.execute(context.async_for_sync)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@then("a TypeError should be raised indicating async_execute is needed")
|
|
def step_assert_type_error_async(context):
|
|
assert context.type_error is not None, "Expected TypeError to be raised"
|
|
assert "async_execute" in str(context.type_error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: RetryContext.execute nesting guard (lines 452-453)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the retry depth is already at maximum")
|
|
def step_set_max_depth(context):
|
|
# Set depth to 1 (the max nesting depth) so the guard kicks in
|
|
context.depth_token = _retry_depth.set(1)
|
|
|
|
def restore():
|
|
_retry_depth.reset(context.depth_token)
|
|
|
|
context.add_cleanup(restore)
|
|
|
|
|
|
@when("I call execute at max nesting depth with a sync function")
|
|
def step_call_execute_at_max_depth(context):
|
|
context.nesting_call_count = 0
|
|
|
|
def _sync_fn():
|
|
context.nesting_call_count += 1
|
|
return "nested_result"
|
|
|
|
context.nesting_result = context.retry_ctx.execute(_sync_fn)
|
|
|
|
|
|
@then("the function should execute once without retry wrapping")
|
|
def step_assert_executed_once(context):
|
|
assert context.nesting_result == "nested_result"
|
|
assert context.nesting_call_count == 1
|
|
|
|
|
|
@then("the attempt count should be 1")
|
|
def step_assert_attempt_count_1(context):
|
|
assert context.retry_ctx.attempt_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: RetryContext.async_execute nesting guard (lines 507-508)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the retry depth is already at maximum for async")
|
|
def step_set_max_depth_async(context):
|
|
context.depth_token_async = _retry_depth.set(1)
|
|
|
|
def restore():
|
|
_retry_depth.reset(context.depth_token_async)
|
|
|
|
context.add_cleanup(restore)
|
|
|
|
|
|
@when("I call async_execute at max nesting depth")
|
|
def step_call_async_execute_at_max_depth(context):
|
|
context.async_nesting_call_count = 0
|
|
|
|
async def _async_fn():
|
|
context.async_nesting_call_count += 1
|
|
return "async_nested_result"
|
|
|
|
context.async_nesting_result = _run_async(
|
|
context.retry_ctx.async_execute(_async_fn)
|
|
)
|
|
|
|
|
|
@then("the async function should execute once without retry wrapping")
|
|
def step_assert_async_executed_once(context):
|
|
assert context.async_nesting_result == "async_nested_result"
|
|
assert context.async_nesting_call_count == 1
|
|
|
|
|
|
@then("the async attempt count should be 1")
|
|
def step_assert_async_attempt_count_1(context):
|
|
assert context.retry_ctx.attempt_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: RetryContext.execute RuntimeError safeguard (line 482)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("Retrying is patched to yield zero iterations")
|
|
def step_patch_retrying_empty(context):
|
|
# Patch is applied in the When step; this step documents intent.
|
|
pass
|
|
|
|
|
|
@when("I call execute expecting a RuntimeError from empty retrying")
|
|
def step_call_execute_empty_retrying(context):
|
|
"""Patch Retrying to return an empty iterator, then call execute."""
|
|
|
|
class _EmptyRetrying:
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
def __iter__(self):
|
|
return iter([])
|
|
|
|
context.execute_runtime_error = None
|
|
with patch("cleveragents.core.retry_service_patterns.Retrying", _EmptyRetrying):
|
|
try:
|
|
context.retry_ctx.execute(lambda: "should_not_run")
|
|
except RuntimeError as exc:
|
|
context.execute_runtime_error = exc
|
|
|
|
|
|
@then("a RuntimeError about Retrying executing at least once should be raised")
|
|
def step_assert_retrying_runtime_error(context):
|
|
assert context.execute_runtime_error is not None, (
|
|
"Expected RuntimeError to be raised"
|
|
)
|
|
assert "at least once" in str(context.execute_runtime_error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: RetryContext.async_execute RuntimeError safeguard (line 537)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("AsyncRetrying is patched to yield zero async iterations")
|
|
def step_patch_async_retrying_empty(context):
|
|
pass # Patch applied in the When step
|
|
|
|
|
|
@when("I call async_execute expecting a RuntimeError from empty async retrying")
|
|
def step_call_async_execute_empty_retrying(context):
|
|
"""Patch AsyncRetrying to return an empty async iterator."""
|
|
|
|
class _EmptyAsyncRetrying:
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
raise StopAsyncIteration
|
|
|
|
async def _async_fn():
|
|
return "should_not_run"
|
|
|
|
context.async_execute_runtime_error = None
|
|
with patch(
|
|
"cleveragents.core.retry_service_patterns.AsyncRetrying",
|
|
_EmptyAsyncRetrying,
|
|
):
|
|
try:
|
|
_run_async(context.retry_ctx.async_execute(_async_fn))
|
|
except RuntimeError as exc:
|
|
context.async_execute_runtime_error = exc
|
|
|
|
|
|
@then("a RuntimeError about AsyncRetrying executing at least once should be raised")
|
|
def step_assert_async_retrying_runtime_error(context):
|
|
assert context.async_execute_runtime_error is not None, (
|
|
"Expected RuntimeError to be raised"
|
|
)
|
|
assert "at least once" in str(context.async_execute_runtime_error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: retry_auto_debug rejects sync callables (lines 575-577)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a synchronous function decorated with retry_auto_debug")
|
|
def step_sync_func_with_auto_debug(context):
|
|
def _sync_fn():
|
|
return "sync_result"
|
|
|
|
context.auto_debug_sync_wrapper = retry_auto_debug(max_debug_attempts=3)(_sync_fn)
|
|
|
|
|
|
@when("I invoke the retry_auto_debug wrapper for the sync function")
|
|
def step_invoke_auto_debug_sync(context):
|
|
context.auto_debug_type_error = None
|
|
try:
|
|
_run_async(context.auto_debug_sync_wrapper())
|
|
except TypeError as exc:
|
|
context.auto_debug_type_error = exc
|
|
|
|
|
|
@then("a TypeError about async callables should be raised")
|
|
def step_assert_auto_debug_type_error(context):
|
|
assert context.auto_debug_type_error is not None, "Expected TypeError to be raised"
|
|
assert "async" in str(context.auto_debug_type_error).lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: retry_auto_debug returns None with zero attempts (line 639)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have an async function decorated with retry_auto_debug with zero attempts")
|
|
def step_auto_debug_zero_attempts(context):
|
|
async def _async_fn():
|
|
return "should_not_be_called"
|
|
|
|
context.auto_debug_zero_wrapper = retry_auto_debug(max_debug_attempts=0)(_async_fn)
|
|
|
|
|
|
@when("I invoke the retry_auto_debug wrapper with zero attempts")
|
|
def step_invoke_auto_debug_zero(context):
|
|
context.auto_debug_zero_result = _run_async(context.auto_debug_zero_wrapper())
|
|
|
|
|
|
@then("rspcov the result should be None from exhausted attempts")
|
|
def step_assert_auto_debug_none(context):
|
|
assert context.auto_debug_zero_result is None, (
|
|
f"Expected None, got {context.auto_debug_zero_result!r}"
|
|
)
|