Skip to content

refactor: restructure adapter invocation logic#71

Merged
touale merged 5 commits intomasterfrom
70-typeerror-deploymenthandle-object-is-not-callable-after-upgrading-framex-kit-to-012b1
Feb 9, 2026
Merged

refactor: restructure adapter invocation logic#71
touale merged 5 commits intomasterfrom
70-typeerror-deploymenthandle-object-is-not-callable-after-upgrading-framex-kit-to-012b1

Conversation

@touale
Copy link
Copy Markdown
Owner

@touale touale commented Feb 9, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a typo in the adapter import path.
  • Refactor

    • Reworked adapter invocation flow to centralize streaming determination and separate sync/async execution paths.
  • Tests

    • Added comprehensive unit tests for adapter selection, local adapter and ray adapter behaviors, and lifecycle/caching.
  • CI / Config

    • Updated test workflow installation options and expanded lint ignores; minor coverage config comment tweak.

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 9, 2026

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/framex/adapter/base.py 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Warning

Rate limit exceeded

@touale has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 23 minutes and 35 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

Refactors adapter invocation flow: adds BaseAdapter._resolve_stream and abstract _invoke/_stream_call/_acall/_call, implements them in LocalAdapter and RayAdapter, fixes a LocalAdapter import typo, updates tests, CI, and lint config.

Changes

Cohort / File(s) Summary
Import Fix
src/framex/adapter/__init__.py
Fix typo in LocalAdapter import path (local_adapyerlocal_adapter).
Base Adapter Refactor
src/framex/adapter/base.py
Add _resolve_stream(api, kwargs). Make _invoke, _stream_call, _acall, and _call abstract. Update call_func to use _resolve_stream and delegate to _stream_call or _invoke. Remove unused inspect import.
Local Adapter Impl
src/framex/adapter/local_adapter.py
Implement _invoke (dispatch to _acall for coroutines, _call otherwise), _acall, _call, and _stream_call to match new BaseAdapter abstractions.
Ray Adapter Impl
src/framex/adapter/ray_adapter.py
Import DeploymentHandle; implement _invoke and _call with checks for coroutines/DeploymentHandle; keep _acall for remote async invocation; update Ray dependency install message.
Tests
tests/adapter/test_init.py, tests/adapter/test_local_adapter.py, tests/adapter/test_ray_adapter.py
Add comprehensive unit tests covering adapter factory, LocalAdapter and RayAdapter behaviors, sync/async/streaming paths, caching/singleton behavior, and mocked Ray interactions.
CI & Lint
.github/workflows/test.yml, ruff.toml, .coveragerc
Adjust UV install extras for Ray in workflow; expand per-file lint ignores for tests; minor comment change in .coveragerc.
Test housekeeping
tests/conftest.py, tests/test_plugins.py
Remove inline noqa comments (formatting-only edits).

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant Base as BaseAdapter
    participant Impl as AdapterImpl (Local/Ray)
    participant Remote as RemoteFunc/DeploymentHandle

    rect rgba(200,220,255,0.5)
    Caller->>Base: call_func(api, **kwargs)
    Base->>Base: _resolve_stream(api, kwargs)
    alt stream = true
        Base->>Impl: _stream_call(func, **kwargs)
        Impl->>Remote: stream generator / remote streaming
        Remote-->>Caller: stream data
    else stream = false
        Base->>Impl: _invoke(func, **kwargs)
        Impl->>Impl: determine coroutine or handle
        alt async / DeploymentHandle
            Impl->>Impl: _acall(func/handle, **kwargs)
            Impl->>Remote: await remote call
            Remote-->>Caller: result
        else sync
            Impl->>Impl: _call(func, **kwargs)
            Impl->>Remote: local call
            Remote-->>Caller: result
        end
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped through adapters, tidy and spry,
I fixed a typo and taught calls to decide,
Stream left or invoke down the lane,
Local or Ray — the paths remain sane,
Happy hops for tests that pass by! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main structural changes to adapter invocation logic across base and concrete adapter implementations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 70-typeerror-deploymenthandle-object-is-not-callable-after-upgrading-framex-kit-to-012b1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@touale
Copy link
Copy Markdown
Owner Author

touale commented Feb 9, 2026

@coderabbitai gererate tests

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/framex/adapter/ray_adapter.py`:
- Around line 66-68: RayAdapter._call currently invokes func(**kwargs) which
fails for Ray DeploymentHandle method references; change RayAdapter._call to
call func.remote(**kwargs) (mirroring _acall) and return its result (or, if you
prefer to disallow sync calls, raise NotImplementedError with a clear message).
Update the implementation referenced by RayAdapter._call so it treats Ray
deployment method references like _acall does (using func.remote) and keep
get_handle_func/deployment_handle/DeploymentHandle logic unchanged.
🧹 Nitpick comments (2)
src/framex/adapter/base.py (1)

27-31: Side-effect on kwargs is subtle but intentional — consider a brief comment.

_resolve_stream mutates the caller's kwargs dict in-place (adding "proxy_path"). This works because call_func passes the same dict to subsequent calls, but it's easy to miss during future maintenance. A one-line comment at line 29 would help.

src/framex/adapter/local_adapter.py (1)

73-75: _stream_call requires async iterables, but lacks type enforcement to prevent sync generators.

In base.py line 40, the result of _stream_call is consumed via async for chunk in self._stream_call(...). This requires the return value to have an __aiter__ method. While the framework's streaming functions are async generators (e.g., echo_stream returns AsyncGenerator[str, None]), there is no type contract or runtime validation preventing someone from mistakenly using a sync generator as a streaming function, which would cause TypeError: 'async for' requires an object with __aiter__ method.

Consider wrapping the result to support both sync and async generators:

Proposed defensive wrapper
     `@override`
-    def _stream_call(self, func: Callable[..., Any], **kwargs: Any) -> Any:
-        return func(**kwargs)
+    async def _stream_call(self, func: Callable[..., Any], **kwargs: Any) -> Any:
+        result = func(**kwargs)
+        if hasattr(result, '__aiter__'):
+            async for chunk in result:
+                yield chunk
+        else:
+            for chunk in result:
+                yield chunk

Comment on lines +66 to +68
@override
def _call(self, func: Callable[..., Any], **kwargs: Any) -> Any:
return func(**kwargs)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's explore the file structure
ls -la src/framex/adapter/

# Look at the ray_adapter.py file around the _call method
cat -n src/framex/adapter/ray_adapter.py | head -100

Repository: touale/FrameX-kit

Length of output: 3356


🏁 Script executed:

#!/bin/bash
# Check what get_handle returns and how it's used
rg -n 'get_handle\b' src/framex/adapter/ray_adapter.py -A 5 -B 2

Repository: touale/FrameX-kit

Length of output: 394


🏁 Script executed:

#!/bin/bash
# Check the _invoke method and routing logic
rg -n '_invoke|_acall|_call' src/framex/adapter/ray_adapter.py -B 2 -A 5

Repository: touale/FrameX-kit

Length of output: 860


🏁 Script executed:

#!/bin/bash
# Look at base.py to understand get_handle_func
rg -n 'get_handle_func\|get_handle' src/framex/adapter/base.py -A 5 -B 2

Repository: touale/FrameX-kit

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check the base.py implementation
cat -n src/framex/adapter/base.py

Repository: touale/FrameX-kit

Length of output: 2984


🏁 Script executed:

#!/bin/bash
# Search for where _invoke is called from
rg -n '_invoke\|get_handle' src/framex/ -A 3 -B 2 --type py

Repository: touale/FrameX-kit

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check local_adapter.py to see how it implements _call
cat -n src/framex/adapter/local_adapter.py

Repository: touale/FrameX-kit

Length of output: 3112


🏁 Script executed:

#!/antml/bash
# Search for all places where _invoke is called
rg -n 'await.*_invoke\|_invoke\(' src/framex/ -B 3 -A 2 --type py

Repository: touale/FrameX-kit

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check for any sync function calls or patterns
rg -n '_call\(' src/framex/adapter/ -B 3 -A 2 --type py

Repository: touale/FrameX-kit

Length of output: 3419


🏁 Script executed:

#!/bin/bash
# Search for tests or usage of _invoke to understand what functions are passed
rg -n 'call_func\|_invoke' src/framex/ -B 5 -A 5 --type py | head -100

Repository: touale/FrameX-kit

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that exercise the _call path
find src/framex -name "*.py" -type f | xargs rg -l 'test.*_invoke\|test.*call_func' 2>/dev/null

Repository: touale/FrameX-kit

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Look for documentation or comments about Ray DeploymentHandle behavior
rg -n 'DeploymentHandle\|method.*ref\|\.remote' src/framex/ --type py

Repository: touale/FrameX-kit

Length of output: 43


RayAdapter._call is reachable but broken for sync remote methods.

When get_handle_func calls getattr(deployment_handle, func_name) on a Ray DeploymentHandle, it returns a method reference—not another DeploymentHandle. For a sync (non-async) method on a Ray deployment:

  • isinstance(func, DeploymentHandle) → False
  • inspect.iscoroutinefunction(func) → False
  • Falls through to _call

However, the current _call implementation (return func(**kwargs)) is incorrect for Ray. Ray method references require .remote() to execute remotely (see _acall's func.remote(**kwargs)). Calling func(**kwargs) directly will fail at runtime because it attempts local execution instead of remote execution.

The _call path should either:

  1. Use .remote() like _acall does, or
  2. Raise an error if sync remote methods are not supported
🤖 Prompt for AI Agents
In `@src/framex/adapter/ray_adapter.py` around lines 66 - 68, RayAdapter._call
currently invokes func(**kwargs) which fails for Ray DeploymentHandle method
references; change RayAdapter._call to call func.remote(**kwargs) (mirroring
_acall) and return its result (or, if you prefer to disallow sync calls, raise
NotImplementedError with a clear message). Update the implementation referenced
by RayAdapter._call so it treats Ray deployment method references like _acall
does (using func.remote) and keep
get_handle_func/deployment_handle/DeploymentHandle logic unchanged.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@touale I'll help you generate tests for this PR. Let me first gather information about the changes and the codebase structure.


🏁 Script executed:

#!/bin/bash

# Get the list of files changed in this PR
echo "=== Files changed in PR ==="
gh pr view 71 --json files --jq '.files[].path'

echo -e "\n=== PR diff ==="
gh pr diff 71

echo -e "\n=== Check for existing test directory structure ==="
fd -t d -d 3 test
fd -t d -d 3 tests
fd -t d -d 3 __tests__

echo -e "\n=== Check for test files ==="
fd -e test.ts -e test.js -e spec.ts -e spec.js

echo -e "\n=== Check package.json for test framework ==="
if [ -f package.json ]; then
    cat package.json | jq '.devDependencies, .dependencies, .scripts' 2>/dev/null || cat package.json
fi

Length of output: 5850


🏁 Script executed:

#!/bin/bash

# Check for Python dependencies
echo "=== Python dependencies (pyproject.toml) ==="
if [ -f pyproject.toml ]; then
    cat pyproject.toml
fi

echo -e "\n=== Existing tests structure ==="
tree tests/ -L 3 2>/dev/null || find tests/ -type f -name "*.py" | head -20

echo -e "\n=== Look for existing adapter tests ==="
fd -e py . tests/ | head -20

echo -e "\n=== Sample existing test file (if any) ==="
FIRST_TEST=$(fd -e py . tests/ | head -1)
if [ -n "$FIRST_TEST" ]; then
    echo "File: $FIRST_TEST"
    cat "$FIRST_TEST"
fi

echo -e "\n=== Check adapter module structure ==="
ls -la src/framex/adapter/

echo -e "\n=== Read full adapter files for context ==="
echo "--- base.py ---"
cat src/framex/adapter/base.py

echo -e "\n--- local_adapter.py ---"
cat src/framex/adapter/local_adapter.py

echo -e "\n--- ray_adapter.py ---"
cat src/framex/adapter/ray_adapter.py

Length of output: 10560


🏁 Script executed:

#!/bin/bash

# Check for any existing adapter tests or related tests
echo "=== Check for existing adapter-related tests ==="
rg -l "adapter" tests/ 2>/dev/null || echo "No adapter tests found"

echo -e "\n=== Check conftest.py for fixtures ==="
cat tests/conftest.py

echo -e "\n=== Check a sample test file for patterns ==="
cat tests/api/test_invoker.py

echo -e "\n=== Check for PluginApi model ==="
fd -e py . src/framex/plugin/ | xargs grep -l "class PluginApi"
cat src/framex/plugin/model.py | head -100

Length of output: 5365


@touale I'll generate comprehensive tests for the adapter refactoring changes. Based on the PR changes, I'll create test suites covering the restructured invocation logic, streaming behavior, and the new abstract method implementations.

Test Suite Plan for Adapter Refactoring

I'll create a comprehensive test suite covering:

  • LocalAdapter implementation tests (async/sync function invocation, streaming)
  • BaseAdapter abstract functionality tests (_resolve_stream, call_func)
  • RayAdapter implementation tests (with proper mocking)
  • Edge cases and error handling

💡 Use @coderabbitai plan to create a plan for code changes

@touale
Copy link
Copy Markdown
Owner Author

touale commented Feb 9, 2026

@coderabbitai

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Tip

For best results, initiate chat on the files or code changes.

@touale, I'm here! How can I help you?

I previously provided a comprehensive test plan for your adapter refactoring changes. Would you like me to:

  1. Proceed with creating those test files by using the `` tag to implement the tests I outlined?
  2. Modify the test plan based on any feedback you have?
  3. Help with something else related to this PR?

Please let me know what you'd like me to do! 🐰


💡 Use @coderabbitai plan to create a plan for code changes

@touale
Copy link
Copy Markdown
Owner Author

touale commented Feb 9, 2026

@coderabbitai generate test code

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Tip

For best results, initiate chat on the files or code changes.

@touale I'll generate the test code for your adapter refactoring changes. Creating comprehensive test suites for the LocalAdapter and BaseAdapter implementations.

Test Suite Implementation Plan

  • Create tests/adapter directory structure
  • Implement LocalAdapter tests covering _invoke, _call, _acall, _stream_call methods
  • Implement BaseAdapter tests covering _resolve_stream and call_func logic
  • Cover edge cases, async/sync scenarios, and streaming behavior

💡 Use @coderabbitai plan to create a plan for code changes

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Note

Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

Copyable Unit Test Edits

Copyable Edits

tests/adapter/init.py

This is a new file.


tests/adapter/conftest.py

This is a new file.

"""Conftest for adapter tests - prevents session fixture from running."""

import pytest


# Override the test_app fixture to prevent it from running for adapter tests
`@pytest.fixture`(scope="session", autouse=False)
def test_app():
    """Disabled test_app fixture for adapter tests."""
    return None

tests/adapter/test_base.py

This is a new file.

"""Tests for framex.adapter.base module."""

from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import FastAPI

from framex.adapter.base import AdapterMode, BaseAdapter
from framex.plugin.model import ApiType, PluginApi


class ConcreteAdapter(BaseAdapter):
    """Concrete implementation of BaseAdapter for testing."""

    mode = AdapterMode.LOCAL

    async def _invoke(self, func: Callable[..., Any], **kwargs: Any) -> Any:
        return await func(**kwargs) if hasattr(func, "__call__") else func

    def get_handle(self, deployment_name: str) -> Any:
        return MagicMock()

    def bind(self, deployment: Callable[..., Any], **kwargs: Any) -> Any:
        return deployment(**kwargs)

    def to_remote_func(self, func: Callable) -> Callable:
        return func

    def _stream_call(self, func: Callable[..., Any], **kwargs: Any) -> Any:
        return func(**kwargs)

    async def _acall(self, func: Callable[..., Any], **kwargs: Any) -> Any:
        return await func(**kwargs)

    def _call(self, func: Callable[..., Any], **kwargs: Any) -> Any:
        return func(**kwargs)


class TestAdapterMode:
    """Tests for AdapterMode enum."""

    def test_adapter_mode_local_value(self):
        """Test LOCAL mode has correct value."""
        assert AdapterMode.LOCAL == "noray"

    def test_adapter_mode_ray_value(self):
        """Test RAY mode has correct value."""
        assert AdapterMode.RAY == "ray"

    def test_adapter_mode_is_str_enum(self):
        """Test AdapterMode is a string enum."""
        assert isinstance(AdapterMode.LOCAL, str)
        assert isinstance(AdapterMode.RAY, str)


class TestBaseAdapter:
    """Tests for BaseAdapter abstract class."""

    def test_base_adapter_has_mode_attribute(self):
        """Test BaseAdapter has mode attribute."""
        adapter = ConcreteAdapter()
        assert hasattr(adapter, "mode")
        assert adapter.mode == AdapterMode.LOCAL

    def test_to_ingress_returns_class_unchanged(self):
        """Test to_ingress returns the class unchanged by default."""
        adapter = ConcreteAdapter()

        class TestClass:
            pass

        app = FastAPI()
        result = adapter.to_ingress(TestClass, app)
        assert result is TestClass

    def test_to_deployment_returns_class_unchanged(self):
        """Test to_deployment returns the class unchanged by default."""
        adapter = ConcreteAdapter()

        class TestClass:
            pass

        result = adapter.to_deployment(TestClass, name="test")
        assert result is TestClass

    `@pytest.mark.asyncio`
    async def test_resolve_stream_with_non_proxy_stream_true(self):
        """Test _resolve_stream returns True when stream=True and not PROXY."""
        adapter = ConcreteAdapter()
        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.FUNC,
            stream=True,
        )
        kwargs = {}
        result = await adapter._resolve_stream(api, kwargs)
        assert result is True

    `@pytest.mark.asyncio`
    async def test_resolve_stream_with_non_proxy_stream_false(self):
        """Test _resolve_stream returns False when stream=False and not PROXY."""
        adapter = ConcreteAdapter()
        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.FUNC,
            stream=False,
        )
        kwargs = {}
        result = await adapter._resolve_stream(api, kwargs)
        assert result is False

    `@pytest.mark.asyncio`
    async def test_resolve_stream_with_proxy_type_and_api(self):
        """Test _resolve_stream checks gen API for PROXY type."""
        adapter = ConcreteAdapter()
        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.PROXY,
            api="/test/path",
            stream=False,
        )
        kwargs = {}

        with patch.object(adapter, "_check_is_gen_api", new=AsyncMock(return_value=True)):
            result = await adapter._resolve_stream(api, kwargs)
            assert result is True
            assert kwargs["proxy_path"] == "/test/path"

    `@pytest.mark.asyncio`
    async def test_resolve_stream_with_proxy_type_no_gen_api(self):
        """Test _resolve_stream returns False when proxy path is not gen API."""
        adapter = ConcreteAdapter()
        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.PROXY,
            api="/test/path",
            stream=False,
        )
        kwargs = {}

        with patch.object(adapter, "_check_is_gen_api", new=AsyncMock(return_value=False)):
            result = await adapter._resolve_stream(api, kwargs)
            assert result is False
            assert kwargs["proxy_path"] == "/test/path"

    `@pytest.mark.asyncio`
    async def test_call_func_non_streaming(self):
        """Test call_func with non-streaming function."""
        adapter = ConcreteAdapter()

        async def test_func(**kwargs):
            return "result"

        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.FUNC,
            stream=False,
        )

        with patch.object(adapter, "get_handle_func", return_value=test_func):
            result = await adapter.call_func(api, param="value")
            assert result == "result"

    `@pytest.mark.asyncio`
    async def test_call_func_streaming(self):
        """Test call_func with streaming function."""
        adapter = ConcreteAdapter()

        async def test_func(**kwargs):
            yield "chunk1"
            yield "chunk2"

        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.FUNC,
            stream=True,
        )

        with patch.object(adapter, "get_handle_func", return_value=test_func):
            result = await adapter.call_func(api)
            assert result == ["chunk1", "chunk2"]

    def test_get_handle_func_success(self):
        """Test get_handle_func returns function from handle."""
        adapter = ConcreteAdapter()

        def test_function():
            return "test"

        mock_handle = MagicMock()
        mock_handle.test_func = test_function

        with patch.object(adapter, "get_handle", return_value=mock_handle):
            func = adapter.get_handle_func("test_deployment", "test_func")
            assert func == test_function

    def test_get_handle_func_no_handle(self):
        """Test get_handle_func raises RuntimeError when handle not found."""
        adapter = ConcreteAdapter()

        with patch.object(adapter, "get_handle", return_value=None):
            with pytest.raises(RuntimeError, match="No handle or function found"):
                adapter.get_handle_func("test_deployment", "test_func")

    def test_get_handle_func_no_function(self):
        """Test get_handle_func raises RuntimeError when function is None."""
        adapter = ConcreteAdapter()

        # Create handle with func set to None to test RuntimeError path
        mock_handle = MagicMock()
        mock_handle.test_func = None

        with patch.object(adapter, "get_handle", return_value=mock_handle):
            with pytest.raises(RuntimeError, match="No handle or function found"):
                adapter.get_handle_func("test_deployment", "test_func")

    `@pytest.mark.asyncio`
    async def test_check_is_gen_api_calls_proxy_plugin(self):
        """Test _check_is_gen_api calls proxy plugin's check function."""
        adapter = ConcreteAdapter()

        async def mock_check_func(**kwargs):
            return True

        with (
            patch.object(adapter, "get_handle_func", return_value=mock_check_func),
            patch.object(adapter, "_acall", new=AsyncMock(return_value=True)) as mock_acall,
        ):
            result = await adapter._check_is_gen_api("/test/path")
            assert result is True
            mock_acall.assert_called_once()

    `@pytest.mark.asyncio`
    async def test_check_is_gen_api_caching(self):
        """Test _check_is_gen_api caches results."""
        adapter = ConcreteAdapter()

        call_count = 0

        async def mock_check_func(**kwargs):
            nonlocal call_count
            call_count += 1
            return True

        with patch.object(adapter, "get_handle_func", return_value=mock_check_func):
            with patch.object(adapter, "_acall", new=AsyncMock(return_value=True)) as mock_acall:
                # First call
                result1 = await adapter._check_is_gen_api("/test/path")
                # Second call with same path should use cache
                result2 = await adapter._check_is_gen_api("/test/path")

                assert result1 is True
                assert result2 is True
                # Should only call once due to caching
                assert mock_acall.call_count == 1

    def test_abstract_methods_not_implemented(self):
        """Test that abstract methods must be implemented."""

        class IncompleteAdapter(BaseAdapter):
            mode = AdapterMode.LOCAL

        with pytest.raises(TypeError, match="Can't instantiate abstract class"):
            IncompleteAdapter()

    `@pytest.mark.asyncio`
    async def test_call_func_with_proxy_api_streaming(self):
        """Test call_func resolves streaming for proxy API correctly."""
        adapter = ConcreteAdapter()

        async def test_func(**kwargs):
            yield "chunk1"
            yield "chunk2"

        api = PluginApi(
            deployment_name="test",
            func_name="test_func",
            call_type=ApiType.PROXY,
            api="/generate/text",
            stream=False,
        )

        with (
            patch.object(adapter, "get_handle_func", return_value=test_func),
            patch.object(adapter, "_check_is_gen_api", new=AsyncMock(return_value=True)),
        ):
            result = await adapter.call_func(api)
            assert result == ["chunk1", "chunk2"]

tests/adapter/test_init.py

This is a new file.

"""Tests for framex.adapter.__init__ module."""

from unittest.mock import MagicMock, patch

import pytest

from framex.adapter import _adapter, get_adapter
from framex.adapter.base import BaseAdapter
from framex.adapter.local_adapter import LocalAdapter


class TestGetAdapter:
    """Tests for the get_adapter factory function."""

    def setup_method(self):
        """Reset the global adapter before each test."""
        import framex.adapter as adapter_module

        adapter_module._adapter = None

    def test_get_adapter_returns_local_adapter_when_ray_disabled(self):
        """Test get_adapter returns LocalAdapter when use_ray is False."""
        with patch("framex.adapter.settings.server.use_ray", False):
            adapter = get_adapter()
            assert isinstance(adapter, LocalAdapter)
            assert isinstance(adapter, BaseAdapter)

    def test_get_adapter_returns_ray_adapter_when_ray_enabled(self):
        """Test get_adapter returns RayAdapter when use_ray is True."""
        with (
            patch("framex.adapter.settings.server.use_ray", True),
            patch("framex.adapter.ray_adapter.RayAdapter") as mock_ray_adapter,
        ):
            mock_instance = MagicMock()
            mock_ray_adapter.return_value = mock_instance

            adapter = get_adapter()
            assert adapter == mock_instance
            mock_ray_adapter.assert_called_once()

    def test_get_adapter_returns_same_instance_on_multiple_calls(self):
        """Test get_adapter returns the same singleton instance."""
        with patch("framex.adapter.settings.server.use_ray", False):
            adapter1 = get_adapter()
            adapter2 = get_adapter()
            assert adapter1 is adapter2

    def test_get_adapter_caches_local_adapter(self):
        """Test that LocalAdapter is cached after first call."""
        with patch("framex.adapter.settings.server.use_ray", False):
            adapter1 = get_adapter()
            # Second call should return cached instance
            adapter2 = get_adapter()
            assert adapter1 is adapter2
            assert isinstance(adapter1, LocalAdapter)

    def test_get_adapter_caches_ray_adapter(self):
        """Test that RayAdapter is cached after first call."""
        with (
            patch("framex.adapter.settings.server.use_ray", True),
            patch("framex.adapter.ray_adapter.RayAdapter") as mock_ray_adapter,
        ):
            mock_instance = MagicMock()
            mock_ray_adapter.return_value = mock_instance

            adapter1 = get_adapter()
            adapter2 = get_adapter()

            # Should only instantiate once
            assert adapter1 is adapter2
            assert mock_ray_adapter.call_count == 1

    def test_get_adapter_lazy_imports_ray_adapter(self):
        """Test that RayAdapter is only imported when needed."""
        with patch("framex.adapter.settings.server.use_ray", False):
            # Import should not happen when use_ray is False
            with patch("framex.adapter.ray_adapter") as mock_ray_module:
                get_adapter()
                # RayAdapter module should not be accessed
                mock_ray_module.RayAdapter.assert_not_called()

    def test_adapter_initially_none(self):
        """Test that _adapter global is None before first call."""
        import framex.adapter as adapter_module

        adapter_module._adapter = None
        assert adapter_module._adapter is None

    def test_adapter_set_after_first_call(self):
        """Test that _adapter global is set after first call."""
        import framex.adapter as adapter_module

        adapter_module._adapter = None
        with patch("framex.adapter.settings.server.use_ray", False):
            get_adapter()
            assert adapter_module._adapter is not None
            assert isinstance(adapter_module._adapter, LocalAdapter)

    def test_get_adapter_with_switching_ray_setting(self):
        """Test that once adapter is set, changing ray setting doesn't affect it."""
        import framex.adapter as adapter_module

        adapter_module._adapter = None

        with patch("framex.adapter.settings.server.use_ray", False):
            adapter1 = get_adapter()
            assert isinstance(adapter1, LocalAdapter)

        # Change setting, but adapter should still be the same
        with patch("framex.adapter.settings.server.use_ray", True):
            adapter2 = get_adapter()
            # Should still be the same LocalAdapter instance
            assert adapter2 is adapter1
            assert isinstance(adapter2, LocalAdapter)

tests/adapter/test_local_adapter.py

This is a new file.

"""Tests for framex.adapter.local_adapter module."""

import asyncio
import threading
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from framex.adapter.base import AdapterMode
from framex.adapter.local_adapter import LocalAdapter


class TestLocalAdapter:
    """Tests for LocalAdapter class."""

    def test_mode_is_local(self):
        """Test LocalAdapter mode is LOCAL."""
        adapter = LocalAdapter()
        assert adapter.mode == AdapterMode.LOCAL

    def test_to_deployment_sets_deployment_name(self):
        """Test to_deployment sets deployment_name attribute on class."""
        adapter = LocalAdapter()

        class TestClass:
            pass

        result = adapter.to_deployment(TestClass, name="my_deployment")
        assert hasattr(result, "deployment_name")
        assert result.deployment_name == "my_deployment"

    def test_to_deployment_without_name(self):
        """Test to_deployment without name doesn't set attribute."""
        adapter = LocalAdapter()

        class TestClass:
            pass

        result = adapter.to_deployment(TestClass)
        assert not hasattr(result, "deployment_name")

    def test_to_deployment_with_other_kwargs(self):
        """Test to_deployment handles other kwargs."""
        adapter = LocalAdapter()

        class TestClass:
            pass

        result = adapter.to_deployment(TestClass, name="test", other_param="value")
        assert hasattr(result, "deployment_name")
        assert result.deployment_name == "test"

    def test_get_handle_returns_deployment(self):
        """Test get_handle returns deployment from app state."""
        adapter = LocalAdapter()

        mock_deployment = MagicMock()
        mock_app = MagicMock()
        mock_app.state.deployments_dict = {"test_deployment": mock_deployment}

        with patch("framex.driver.ingress.app", mock_app):
            handle = adapter.get_handle("test_deployment")
            assert handle == mock_deployment

    def test_get_handle_returns_ingress_for_backend(self):
        """Test get_handle returns ingress for BACKEND_NAME."""
        adapter = LocalAdapter()

        mock_ingress = MagicMock()
        mock_app = MagicMock()
        mock_app.state.deployments_dict = {}
        mock_app.state.ingress = mock_ingress

        with (
            patch("framex.driver.ingress.app", mock_app),
            patch("framex.consts.BACKEND_NAME", "backend"),
        ):
            handle = adapter.get_handle("backend")
            assert handle == mock_ingress

    def test_get_handle_returns_none_when_not_found(self):
        """Test get_handle returns None when deployment not found."""
        adapter = LocalAdapter()

        mock_app = MagicMock()
        mock_app.state.deployments_dict = {}

        with patch("framex.driver.ingress.app", mock_app):
            handle = adapter.get_handle("nonexistent")
            assert handle is None

    def test_bind_calls_deployment_with_kwargs(self):
        """Test bind calls deployment function with kwargs."""
        adapter = LocalAdapter()

        def mock_deployment(**kwargs):
            return kwargs

        result = adapter.bind(mock_deployment, param1="value1", param2="value2")
        assert result == {"param1": "value1", "param2": "value2"}

    def test_bind_with_no_kwargs(self):
        """Test bind calls deployment with no kwargs."""
        adapter = LocalAdapter()

        def mock_deployment(**kwargs):
            return "called"

        result = adapter.bind(mock_deployment)
        assert result == "called"

    def test_safe_plot_wrapper_uses_lock(self):
        """Test _safe_plot_wrapper uses thread lock."""
        adapter = LocalAdapter()
        call_order = []
        lock_acquired = []

        def mock_func(*args, **kwargs):
            # Check if lock is held
            lock_acquired.append(threading.current_thread().ident)
            call_order.append("func")
            return "result"

        result = adapter._safe_plot_wrapper(mock_func, "arg1", kwarg1="value1")
        assert result == "result"
        assert len(lock_acquired) == 1

    def test_safe_plot_wrapper_serializes_calls(self):
        """Test _safe_plot_wrapper serializes concurrent calls."""
        adapter = LocalAdapter()
        call_order = []
        lock = threading.Lock()

        def slow_func(name):
            call_order.append(f"{name}_start")
            threading.Event().wait(0.01)  # Small delay
            call_order.append(f"{name}_end")
            return name

        # Run two calls concurrently
        def run_wrapper(name):
            return adapter._safe_plot_wrapper(slow_func, name)

        thread1 = threading.Thread(target=run_wrapper, args=("call1",))
        thread2 = threading.Thread(target=run_wrapper, args=("call2",))

        thread1.start()
        thread2.start()
        thread1.join()
        thread2.join()

        # Both calls should complete (in any order due to thread scheduling)
        assert "call1_start" in call_order
        assert "call1_end" in call_order
        assert "call2_start" in call_order
        assert "call2_end" in call_order

    `@pytest.mark.asyncio`
    async def test_to_remote_func_with_async_function(self):
        """Test to_remote_func handles async functions."""
        adapter = LocalAdapter()

        async def async_func(value):
            return value * 2

        wrapped_func = adapter.to_remote_func(async_func)
        assert hasattr(wrapped_func, "remote")

        result = await wrapped_func.remote(5)
        assert result == 10

    `@pytest.mark.asyncio`
    async def test_to_remote_func_with_sync_function(self):
        """Test to_remote_func handles sync functions with asyncio.to_thread."""
        adapter = LocalAdapter()

        def sync_func(value):
            return value * 3

        wrapped_func = adapter.to_remote_func(sync_func)
        assert hasattr(wrapped_func, "remote")

        result = await wrapped_func.remote(5)
        assert result == 15

    `@pytest.mark.asyncio`
    async def test_to_remote_func_with_sync_function_uses_safe_wrapper(self):
        """Test to_remote_func uses _safe_plot_wrapper for sync functions."""
        adapter = LocalAdapter()

        def sync_func(value):
            return value + 1

        with patch.object(adapter, "_safe_plot_wrapper", return_value=11) as mock_wrapper:
            wrapped_func = adapter.to_remote_func(sync_func)
            result = await wrapped_func.remote(10)

            # Verify safe wrapper was used
            mock_wrapper.assert_called_once_with(sync_func, 10)
            assert result == 11

    `@pytest.mark.asyncio`
    async def test_invoke_with_async_function(self):
        """Test _invoke delegates to _acall for async functions."""
        adapter = LocalAdapter()

        async def async_func(**kwargs):
            return "async_result"

        result = await adapter._invoke(async_func, param="value")
        assert result == "async_result"

    `@pytest.mark.asyncio`
    async def test_invoke_with_sync_function(self):
        """Test _invoke delegates to _call for sync functions."""
        adapter = LocalAdapter()

        def sync_func(**kwargs):
            return "sync_result"

        result = await adapter._invoke(sync_func, param="value")
        assert result == "sync_result"

    `@pytest.mark.asyncio`
    async def test_acall_awaits_async_function(self):
        """Test _acall awaits async function with kwargs."""
        adapter = LocalAdapter()

        async def async_func(**kwargs):
            return kwargs

        result = await adapter._acall(async_func, key1="value1", key2="value2")
        assert result == {"key1": "value1", "key2": "value2"}

    def test_call_invokes_sync_function(self):
        """Test _call invokes sync function with kwargs."""
        adapter = LocalAdapter()

        def sync_func(**kwargs):
            return kwargs

        result = adapter._call(sync_func, key1="value1", key2="value2")
        assert result == {"key1": "value1", "key2": "value2"}

    def test_stream_call_invokes_function(self):
        """Test _stream_call invokes function with kwargs."""
        adapter = LocalAdapter()

        def stream_func(**kwargs):
            yield from kwargs.values()

        result = adapter._stream_call(stream_func, key1="value1", key2="value2")
        values = list(result)
        assert "value1" in values
        assert "value2" in values

    `@pytest.mark.asyncio`
    async def test_stream_call_with_async_generator(self):
        """Test _stream_call works with async generators."""
        adapter = LocalAdapter()

        async def async_stream(**kwargs):
            for value in kwargs.values():
                yield value

        result = adapter._stream_call(async_stream, key1="value1", key2="value2")
        values = []
        async for value in result:
            values.append(value)
        assert "value1" in values
        assert "value2" in values

    def test_to_remote_func_preserves_original_function(self):
        """Test to_remote_func preserves the original function."""
        adapter = LocalAdapter()

        def original_func(x):
            return x * 2

        wrapped_func = adapter.to_remote_func(original_func)
        # Original function should still be callable
        assert wrapped_func(5) == 10

    `@pytest.mark.asyncio`
    async def test_to_remote_func_remote_attribute(self):
        """Test to_remote_func adds remote attribute."""
        adapter = LocalAdapter()

        def sync_func(x):
            return x + 1

        wrapped_func = adapter.to_remote_func(sync_func)
        assert hasattr(wrapped_func, "remote")
        assert callable(wrapped_func.remote)

    `@pytest.mark.asyncio`
    async def test_concurrent_safe_plot_wrapper_calls(self):
        """Test multiple concurrent calls to _safe_plot_wrapper are serialized."""
        adapter = LocalAdapter()
        results = []

        def counting_func(n):
            import time

            time.sleep(0.001)  # Small delay to ensure overlap without lock
            results.append(n)
            return n

        async def async_wrapper(n):
            return await asyncio.to_thread(adapter._safe_plot_wrapper, counting_func, n)

        # Run multiple concurrent calls
        await asyncio.gather(*[async_wrapper(i) for i in range(5)])

        # All calls should complete
        assert len(results) == 5
        assert set(results) == {0, 1, 2, 3, 4}

    def test_get_handle_with_empty_deployments_dict(self):
        """Test get_handle with empty deployments dict."""
        adapter = LocalAdapter()

        mock_app = MagicMock()
        mock_app.state.deployments_dict = {}
        mock_app.state.ingress = None

        with patch("framex.driver.ingress.app", mock_app):
            handle = adapter.get_handle("any_deployment")
            assert handle is None

    `@pytest.mark.asyncio`
    async def test_invoke_preserves_kwargs(self):
        """Test _invoke preserves kwargs correctly."""
        adapter = LocalAdapter()

        async def async_func(**kwargs):
            return kwargs

        result = await adapter._invoke(async_func, a=1, b=2, c=3)
        assert result == {"a": 1, "b": 2, "c": 3}

tests/adapter/test_ray_adapter.py

This is a new file.

"""Tests for framex.adapter.ray_adapter module."""

import asyncio
import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import FastAPI

from framex.adapter.base import AdapterMode


class TestRayAdapterImport:
    """Tests for RayAdapter import error handling."""

    def test_import_error_without_ray(self):
        """Test that importing RayAdapter without ray raises RuntimeError."""
        # Remove ray from sys.modules if it exists
        ray_modules = [key for key in sys.modules.keys() if key.startswith("ray")]
        saved_modules = {}
        for mod in ray_modules:
            saved_modules[mod] = sys.modules.pop(mod, None)

        try:
            with patch.dict("sys.modules", {"ray": None, "ray.serve": None}):
                with pytest.raises(RuntimeError, match='Ray engine requires extra dependency'):
                    # Force reload to trigger ImportError
                    import importlib

                    import framex.adapter.ray_adapter

                    importlib.reload(framex.adapter.ray_adapter)
        finally:
            # Restore modules
            for mod, val in saved_modules.items():
                if val is not None:
                    sys.modules[mod] = val


`@pytest.fixture`
def mock_ray():
    """Mock ray and ray.serve modules."""
    mock_ray_module = MagicMock()
    mock_serve_module = MagicMock()
    mock_deployment_handle = MagicMock()

    with (
        patch.dict(
            "sys.modules",
            {
                "ray": mock_ray_module,
                "ray.serve": mock_serve_module,
                "ray.serve.handle": MagicMock(DeploymentHandle=mock_deployment_handle),
            },
        ),
        patch("framex.adapter.ray_adapter.ray", mock_ray_module),
        patch("framex.adapter.ray_adapter.serve", mock_serve_module),
        patch("framex.adapter.ray_adapter.DeploymentHandle", mock_deployment_handle),
    ):
        yield mock_ray_module, mock_serve_module, mock_deployment_handle


class TestRayAdapter:
    """Tests for RayAdapter class with mocked ray dependencies."""

    def test_mode_is_ray(self, mock_ray):
        """Test RayAdapter mode is RAY."""
        from framex.adapter.ray_adapter import RayAdapter

        adapter = RayAdapter()
        assert adapter.mode == AdapterMode.RAY

    def test_to_ingress_calls_serve_ingress(self, mock_ray):
        """Test to_ingress calls serve.ingress and to_deployment."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        class TestClass:
            pass

        app = FastAPI()

        # Setup mock chain
        mock_ingress_decorator = MagicMock()
        mock_ingress_result = MagicMock()
        mock_ingress_decorator.return_value = mock_ingress_result
        mock_serve_module.ingress.return_value = mock_ingress_decorator

        mock_deployment_decorator = MagicMock()
        mock_deployment_result = MagicMock()
        mock_deployment_decorator.return_value = mock_deployment_result
        mock_serve_module.deployment.return_value = mock_deployment_decorator

        result = adapter.to_ingress(TestClass, app, name="test")

        mock_serve_module.ingress.assert_called_once_with(app)
        mock_ingress_decorator.assert_called_once_with(TestClass)
        mock_serve_module.deployment.assert_called_once_with(name="test")

    def test_to_deployment_calls_serve_deployment(self, mock_ray):
        """Test to_deployment calls serve.deployment with kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        class TestClass:
            pass

        mock_decorator = MagicMock()
        mock_result = MagicMock()
        mock_decorator.return_value = mock_result
        mock_serve_module.deployment.return_value = mock_decorator

        result = adapter.to_deployment(TestClass, name="test", num_replicas=3)

        mock_serve_module.deployment.assert_called_once_with(name="test", num_replicas=3)
        mock_decorator.assert_called_once_with(TestClass)

    def test_to_remote_func_with_sync_function(self, mock_ray):
        """Test to_remote_func wraps sync function with ray.remote."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        def sync_func(x):
            return x * 2

        mock_remote_func = MagicMock()
        mock_ray_module.remote.return_value = mock_remote_func

        result = adapter.to_remote_func(sync_func)

        mock_ray_module.remote.assert_called_once()
        assert result == mock_remote_func

    def test_to_remote_func_with_async_function(self, mock_ray):
        """Test to_remote_func wraps async function to sync then ray.remote."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        async def async_func(x):
            return x * 2

        mock_remote_func = MagicMock()
        mock_ray_module.remote.return_value = mock_remote_func

        result = adapter.to_remote_func(async_func)

        # Should wrap async function and call ray.remote
        mock_ray_module.remote.assert_called_once()
        assert result == mock_remote_func

    def test_to_remote_func_async_wrapper_runs_asyncio(self, mock_ray):
        """Test that async wrapper uses asyncio.run."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        call_log = []

        async def async_func(x):
            call_log.append(f"called with {x}")
            return x * 2

        # Capture the wrapper function passed to ray.remote
        captured_wrapper = None

        def capture_remote(func):
            nonlocal captured_wrapper
            captured_wrapper = func
            return MagicMock()

        mock_ray_module.remote = capture_remote

        adapter.to_remote_func(async_func)

        # Now test the captured wrapper
        assert captured_wrapper is not None
        with patch("asyncio.run") as mock_asyncio_run:
            mock_asyncio_run.return_value = 10
            result = captured_wrapper(5)
            mock_asyncio_run.assert_called_once()
            assert result == 10

    def test_get_handle_calls_serve_get_deployment_handle(self, mock_ray):
        """Test get_handle calls serve.get_deployment_handle."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_handle = MagicMock()
        mock_serve_module.get_deployment_handle.return_value = mock_handle

        with patch("framex.adapter.ray_adapter.APP_NAME", "test_app"):
            result = adapter.get_handle("test_deployment")

        mock_serve_module.get_deployment_handle.assert_called_once_with("test_deployment", app_name="test_app")
        assert result == mock_handle

    def test_bind_calls_deployment_bind(self, mock_ray):
        """Test bind calls deployment.bind with kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_deployment = MagicMock()
        mock_bound = MagicMock()
        mock_deployment.bind.return_value = mock_bound

        result = adapter.bind(mock_deployment, param1="value1", param2="value2")

        mock_deployment.bind.assert_called_once_with(param1="value1", param2="value2")
        assert result == mock_bound

    def test_stream_call_uses_options_stream(self, mock_ray):
        """Test _stream_call uses options(stream=True).remote."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_func = MagicMock()
        mock_options = MagicMock()
        mock_remote_result = MagicMock()
        mock_func.options.return_value = mock_options
        mock_options.remote.return_value = mock_remote_result

        result = adapter._stream_call(mock_func, param="value")

        mock_func.options.assert_called_once_with(stream=True)
        mock_options.remote.assert_called_once_with(param="value")
        assert result == mock_remote_result

    `@pytest.mark.asyncio`
    async def test_acall_awaits_remote(self, mock_ray):
        """Test _acall awaits func.remote."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_func = MagicMock()
        mock_remote = AsyncMock(return_value="result")
        mock_func.remote = mock_remote

        result = await adapter._acall(mock_func, param="value")

        mock_remote.assert_called_once_with(param="value")
        assert result == "result"

    `@pytest.mark.asyncio`
    async def test_invoke_with_async_function(self, mock_ray):
        """Test _invoke delegates to _acall for async functions."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, mock_deployment_handle = mock_ray
        adapter = RayAdapter()

        async def async_func(**kwargs):
            return "async_result"

        # Patch isinstance to always return False for DeploymentHandle check
        with (
            patch(
                "framex.adapter.ray_adapter.isinstance",
                side_effect=lambda obj, cls: False if cls is mock_deployment_handle else isinstance(obj, cls),
            ),
            patch.object(adapter, "_acall", new=AsyncMock(return_value="async_result")) as mock_acall,
        ):
            result = await adapter._invoke(async_func, param="value")
            mock_acall.assert_called_once_with(async_func, param="value")
            assert result == "async_result"

    `@pytest.mark.asyncio`
    async def test_invoke_with_deployment_handle(self, mock_ray):
        """Test _invoke delegates to _acall for DeploymentHandle-like object."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, mock_deployment_handle = mock_ray
        adapter = RayAdapter()

        # Create a mock that will match isinstance check
        # We'll patch inspect.iscoroutinefunction to return False
        # and the isinstance(func, DeploymentHandle) will be checked
        mock_handle = MagicMock()

        with (
            patch("inspect.iscoroutinefunction", return_value=False),
            patch(
                "framex.adapter.ray_adapter.isinstance",
                side_effect=lambda obj, cls: obj is mock_handle and cls is mock_deployment_handle,
            ),
            patch.object(adapter, "_acall", new=AsyncMock(return_value="handle_result")) as mock_acall,
        ):
            result = await adapter._invoke(mock_handle, param="value")
            mock_acall.assert_called_once_with(mock_handle, param="value")
            assert result == "handle_result"

    `@pytest.mark.asyncio`
    async def test_invoke_with_sync_function(self, mock_ray):
        """Test _invoke delegates to _call for sync functions."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, mock_deployment_handle = mock_ray
        adapter = RayAdapter()

        def sync_func(**kwargs):
            return "sync_result"

        # Patch isinstance to always return False for DeploymentHandle check
        with (
            patch(
                "framex.adapter.ray_adapter.isinstance",
                side_effect=lambda obj, cls: False if cls is mock_deployment_handle else isinstance(obj, cls),
            ),
            patch.object(adapter, "_call", return_value="sync_result") as mock_call,
        ):
            result = await adapter._invoke(sync_func, param="value")
            mock_call.assert_called_once_with(sync_func, param="value")
            assert result == "sync_result"

    def test_call_invokes_function_directly(self, mock_ray):
        """Test _call invokes function directly with kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        def mock_func(**kwargs):
            return kwargs

        result = adapter._call(mock_func, key1="value1", key2="value2")
        assert result == {"key1": "value1", "key2": "value2"}

    def test_bind_with_no_kwargs(self, mock_ray):
        """Test bind with no kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_deployment = MagicMock()
        mock_bound = MagicMock()
        mock_deployment.bind.return_value = mock_bound

        result = adapter.bind(mock_deployment)

        mock_deployment.bind.assert_called_once_with()
        assert result == mock_bound

    def test_stream_call_with_multiple_kwargs(self, mock_ray):
        """Test _stream_call passes all kwargs correctly."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_func = MagicMock()
        mock_options = MagicMock()
        mock_remote_result = MagicMock()
        mock_func.options.return_value = mock_options
        mock_options.remote.return_value = mock_remote_result

        result = adapter._stream_call(mock_func, a=1, b=2, c=3)

        mock_options.remote.assert_called_once_with(a=1, b=2, c=3)
        assert result == mock_remote_result

    def test_to_deployment_with_no_kwargs(self, mock_ray):
        """Test to_deployment with no kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        class TestClass:
            pass

        mock_decorator = MagicMock()
        mock_result = MagicMock()
        mock_decorator.return_value = mock_result
        mock_serve_module.deployment.return_value = mock_decorator

        result = adapter.to_deployment(TestClass)

        mock_serve_module.deployment.assert_called_once_with()
        mock_decorator.assert_called_once_with(TestClass)

    `@pytest.mark.asyncio`
    async def test_acall_with_no_kwargs(self, mock_ray):
        """Test _acall with no kwargs."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_func = MagicMock()
        mock_remote = AsyncMock(return_value="result")
        mock_func.remote = mock_remote

        result = await adapter._acall(mock_func)

        mock_remote.assert_called_once_with()
        assert result == "result"

    def test_get_handle_with_different_app_name(self, mock_ray):
        """Test get_handle uses APP_NAME constant correctly."""
        from framex.adapter.ray_adapter import RayAdapter

        mock_ray_module, mock_serve_module, _ = mock_ray
        adapter = RayAdapter()

        mock_handle = MagicMock()
        mock_serve_module.get_deployment_handle.return_value = mock_handle

        with patch("framex.adapter.ray_adapter.APP_NAME", "custom_app"):
            result = adapter.get_handle("my_deployment")

        mock_serve_module.get_deployment_handle.assert_called_once_with("my_deployment", app_name="custom_app")
        assert result == mock_handle

@touale touale merged commit b2d5b91 into master Feb 9, 2026
8 checks passed
@touale touale deleted the 70-typeerror-deploymenthandle-object-is-not-callable-after-upgrading-framex-kit-to-012b1 branch February 9, 2026 06:28
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@tests/adapter/test_local_adapter.py`:
- Around line 189-194: The test triggers a mypy error because
adapter.to_remote_func returns a Callable but the test calls a dynamically
attached attribute wrapped_func.remote which static typing doesn't recognize;
update the test to silence mypy by adding a type ignore on the .remote call
(i.e., add "  # type: ignore" to the wrapped_func.remote invocation in the test
that uses adapter.to_remote_func and wrapped_func) so the pattern matches the
existing production code ignores.
🧹 Nitpick comments (3)
tests/adapter/test_local_adapter.py (1)

126-154: Serialization test doesn't actually verify serialization.

The test name claims to verify that _safe_plot_wrapper serializes concurrent calls, but the assertions only check that both calls completed — not that they were serialized (e.g., call1_end appears before call2_start or vice versa). Also, lock on line 130 is unused dead code.

♻️ Suggested improvement
     def test_safe_plot_wrapper_serializes_calls(self):
         """Test _safe_plot_wrapper serializes concurrent calls."""
         adapter = LocalAdapter()
         call_order = []
-        lock = threading.Lock()  # noqa
 
         def slow_func(name):
             call_order.append(f"{name}_start")
             threading.Event().wait(0.01)  # Small delay
             call_order.append(f"{name}_end")
             return name
 
         # Run two calls concurrently
         def run_wrapper(name):
             return adapter._safe_plot_wrapper(slow_func, name)
 
         thread1 = threading.Thread(target=run_wrapper, args=("call1",))
         thread2 = threading.Thread(target=run_wrapper, args=("call2",))
 
         thread1.start()
         thread2.start()
         thread1.join()
         thread2.join()
 
-        # Both calls should complete (in any order due to thread scheduling)
-        assert "call1_start" in call_order
-        assert "call1_end" in call_order
-        assert "call2_start" in call_order
-        assert "call2_end" in call_order
+        # Verify serialization: each call's start/end should be adjacent
+        assert len(call_order) == 4
+        # Either call1 ran fully first, or call2 ran fully first
+        assert call_order in [
+            ["call1_start", "call1_end", "call2_start", "call2_end"],
+            ["call2_start", "call2_end", "call1_start", "call1_end"],
+        ]
tests/adapter/test_ray_adapter.py (1)

23-36: Remove unused blanket noqa directives (lines 24-25) as flagged by Ruff.

These blanket noqa comments are no longer suppressing any diagnostics.

🔧 Proposed fix
         try:
-            with patch.dict("sys.modules", {"ray": None, "ray.serve": None}):  # noqa
-                with pytest.raises(RuntimeError, match="Ray engine requires extra dependency"):  # noqa
+            with patch.dict("sys.modules", {"ray": None, "ray.serve": None}):
+                with pytest.raises(RuntimeError, match="Ray engine requires extra dependency"):
                     # Force reload to trigger ImportError
                     import importlib
tests/adapter/test_init.py (1)

71-78: Lazy-import test is trivially true and doesn't actually verify the import mechanism.

When use_ray=False, get_adapter() never reaches the code path that touches RayAdapter at all, so mock_ray_module.RayAdapter.assert_not_called() always passes regardless of the patch. This test doesn't meaningfully verify lazy importing — it only confirms the use_ray=False path doesn't instantiate RayAdapter, which is already covered by test_get_adapter_returns_local_adapter_when_ray_disabled.

Also, the blanket # noqa on line 73 is unused per Ruff.

♻️ Consider replacing with a more meaningful lazy-import test or removing
     def test_get_adapter_lazy_imports_ray_adapter(self):
         """Test that RayAdapter is only imported when needed."""
-        with patch("framex.adapter.settings.server.use_ray", False):  # noqa
-            # Import should not happen when use_ray is False
-            with patch("framex.adapter.ray_adapter") as mock_ray_module:
-                get_adapter()
-                # RayAdapter module should not be accessed
-                mock_ray_module.RayAdapter.assert_not_called()
+        import sys
+        # Ensure ray_adapter hasn't been imported as a side effect
+        with patch("framex.adapter.settings.server.use_ray", False):
+            get_adapter()
+            # Verify we got a LocalAdapter without importing RayAdapter class
+            import framex.adapter as adapter_mod
+            assert isinstance(adapter_mod._adapter, LocalAdapter)

Comment thread tests/adapter/test_local_adapter.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeError: 'DeploymentHandle' object is not callable after upgrading framex-kit to 0.12.b1

1 participant