diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bad64b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + + - name: Install dependencies + run: pip install .[dev] + + - name: Ruff + run: ruff check . + + - name: Mypy + run: mypy src tests + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: pip install .[dev] + + - name: Pytest + run: pytest diff --git a/examples/custom_tracer.py b/examples/custom_tracer.py index 57844ce..09ec8ac 100644 --- a/examples/custom_tracer.py +++ b/examples/custom_tracer.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from tsunagi import Pipeline, Tracer, step +from tsunagi import Pipeline, step class JSONLTracer: diff --git a/pyproject.toml b/pyproject.toml index 182e91d..545646d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,10 @@ strict = true warn_return_any = true warn_unused_configs = true +[[tool.mypy.overrides]] +module = "tests.*" +disable_error_code = ["arg-type"] + [tool.ruff] target-version = "py311" line-length = 100 diff --git a/src/tsunagi/agent.py b/src/tsunagi/agent.py index 1511fd7..bbafa5b 100644 --- a/src/tsunagi/agent.py +++ b/src/tsunagi/agent.py @@ -16,12 +16,16 @@ import json from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from tsunagi.context import Context -from tsunagi.tool import Tool from tsunagi.tracer import NullTracer, Tracer +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from tsunagi.tool import Tool + @dataclass class Message: @@ -88,9 +92,10 @@ def __init__( def _get_client(self) -> Any: if self._client is None: - from anthropic import AsyncAnthropic + import importlib - self._client = AsyncAnthropic(**self.client_kwargs) + anthropic = importlib.import_module("anthropic") + self._client = anthropic.AsyncAnthropic(**self.client_kwargs) return self._client async def send( @@ -148,9 +153,10 @@ def __init__( def _get_client(self) -> Any: if self._client is None: - from openai import AsyncOpenAI + import importlib - self._client = AsyncOpenAI(**self.client_kwargs) + openai_module = importlib.import_module("openai") + self._client = openai_module.AsyncOpenAI(**self.client_kwargs) return self._client async def send( @@ -247,10 +253,7 @@ async def chat(self, user_message: str) -> str: await self._tracer.on_step_start(ctx, tc.name, tc.arguments) try: result = await tool_obj(**tc.arguments) - if isinstance(result, str): - result_str = result - else: - result_str = json.dumps(result) + result_str = result if isinstance(result, str) else json.dumps(result) await self._tracer.on_step_end(ctx, tc.name, result_str) except Exception as e: # pragma: no cover - exercised in tests result_str = json.dumps({"error": str(e)}) diff --git a/src/tsunagi/pipeline.py b/src/tsunagi/pipeline.py index c34ca8e..03c2397 100644 --- a/src/tsunagi/pipeline.py +++ b/src/tsunagi/pipeline.py @@ -142,7 +142,7 @@ async def run_parallel( step_inputs = [input] * len(steps) try: - tasks = [s.execute(inp) for s, inp in zip(steps, step_inputs)] + tasks = [s.execute(inp) for s, inp in zip(steps, step_inputs, strict=True)] return list(await asyncio.gather(*tasks)) except Exception as e: # pragma: no cover - handled in pipeline tests raise PipelineError("parallel", e) from e diff --git a/src/tsunagi/retry.py b/src/tsunagi/retry.py index 10214aa..67921ad 100644 --- a/src/tsunagi/retry.py +++ b/src/tsunagi/retry.py @@ -8,7 +8,10 @@ import random from dataclasses import dataclass -from typing import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable @dataclass(frozen=True) diff --git a/src/tsunagi/step.py b/src/tsunagi/step.py index 3a1c774..16d6e8e 100644 --- a/src/tsunagi/step.py +++ b/src/tsunagi/step.py @@ -15,12 +15,15 @@ import asyncio import functools import inspect -from typing import Any, Awaitable, Callable, TypeVar, overload +from typing import TYPE_CHECKING, Any, TypeVar, overload from tsunagi.errors import StepError from tsunagi.errors import TimeoutError as TsunagiTimeoutError from tsunagi.retry import NO_RETRY, RetryConfig +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + T = TypeVar("T") @@ -70,19 +73,23 @@ async def execute(self, *args: Any, **kwargs: Any) -> Any: """ last_error: Exception | None = None + timeout_seconds = self.timeout_seconds + for attempt in range(self.retry.max_attempts): try: - if self.timeout_seconds is not None: + if timeout_seconds is not None: result = await asyncio.wait_for( self.fn(*args, **kwargs), - timeout=self.timeout_seconds, + timeout=timeout_seconds, ) else: result = await self.fn(*args, **kwargs) return result - except asyncio.TimeoutError: - raise TsunagiTimeoutError(self.name, self.timeout_seconds) from None + except TimeoutError: + if timeout_seconds is None: + raise + raise TsunagiTimeoutError(self.name, timeout_seconds) from None except Exception as e: last_error = e @@ -150,7 +157,7 @@ async def bound_fn(*a: Any, **kw: Any) -> Any: try: return await self._original.execute(*args) finally: - self._original.fn = original_fn # type: ignore[assignment] + self._original.fn = original_fn class StepSequence: diff --git a/src/tsunagi/tool.py b/src/tsunagi/tool.py index 2aaa491..c4f7120 100644 --- a/src/tsunagi/tool.py +++ b/src/tsunagi/tool.py @@ -8,7 +8,10 @@ from __future__ import annotations import inspect -from typing import Any, Awaitable, Callable, get_type_hints +from typing import TYPE_CHECKING, Any, get_type_hints + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable class Tool: diff --git a/src/tsunagi/tracer.py b/src/tsunagi/tracer.py index add0247..2359004 100644 --- a/src/tsunagi/tracer.py +++ b/src/tsunagi/tracer.py @@ -7,9 +7,10 @@ from __future__ import annotations import sys -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable -from tsunagi.context import Context +if TYPE_CHECKING: + from tsunagi.context import Context @runtime_checkable diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_agent.py b/tests/test_agent.py index 8b8f135..eff8642 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +from typing import TYPE_CHECKING, Any import pytest from tsunagi import Agent, AgentEvent, ToolCall, tool -from tsunagi.agent import Message + +if TYPE_CHECKING: + from tsunagi.agent import Message class TextAdapter: @@ -88,7 +91,9 @@ async def test_agent_unknown_tool() -> None: assert result == "final" tool_result_message = adapter.seen_messages[-1][-1] - content = tool_result_message.content[0]["content"] + assert isinstance(tool_result_message.content, list) + content_list: list[dict[str, Any]] = tool_result_message.content + content = content_list[0]["content"] assert "Unknown tool" in content @@ -101,7 +106,7 @@ async def test_agent_stream() -> None: agent = Agent(adapter) agent.register(adder) - events = [event async for event in agent.stream("calc")] # type: ignore[misc] + events = [event async for event in agent.stream("calc")] assert events[0] == AgentEvent( type="tool_call", tool="adder", args={"a": 1, "b": 2} diff --git a/tests/test_integration.py b/tests/test_integration.py index 7f55bf2..e741d04 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,8 +2,8 @@ import pytest -from tsunagi import Pipeline from tests.conftest import add_one, double, to_string +from tsunagi import Pipeline @pytest.mark.asyncio diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index afa225c..26e7a37 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -2,8 +2,8 @@ import pytest -from tsunagi import NullTracer, Pipeline, PipelineError from tests.conftest import add_one, always_fail, double, to_string +from tsunagi import NullTracer, Pipeline, PipelineError class MockTracer: diff --git a/tests/test_retry.py b/tests/test_retry.py index 9d12f43..12a128c 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -2,9 +2,9 @@ import pytest -from tsunagi import NO_RETRY, RetryConfig from tests import conftest from tests.conftest import fail_twice_then_succeed +from tsunagi import NO_RETRY, RetryConfig def test_no_retry_default() -> None: diff --git a/tests/test_step.py b/tests/test_step.py index a2dd0d5..ccb9840 100644 --- a/tests/test_step.py +++ b/tests/test_step.py @@ -2,8 +2,8 @@ import pytest -from tsunagi import StepError, StepSequence, step from tests.conftest import add_one, always_fail, double, to_string +from tsunagi import StepError, StepSequence, step @pytest.mark.asyncio