Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
- name: Ruff format
run: uvx ruff format --check crates/bashkit-python

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Mypy type check
run: |
pip install mypy
mypy crates/bashkit-python/bashkit/ --ignore-missing-imports

test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class BashTool:
def system_prompt(self) -> str: ...
def input_schema(self) -> str: ...
def output_schema(self) -> str: ...
def reset(self) -> None: ...

class ScriptedTool:
"""Compose Python callbacks as bash builtins for multi-tool orchestration.
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,11 @@ select = ["E", "F", "W", "I", "UP"]
[tool.ruff.lint.isort]
known-first-party = ["bashkit"]

[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true

[tool.pytest.ini_options]
asyncio_mode = "auto"
126 changes: 126 additions & 0 deletions crates/bashkit-python/tests/test_bashkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,129 @@ def test_scripted_tool_dozen_tools():
assert r.exit_code == 0
lines = r.stdout.strip().splitlines()
assert lines == [f"result-{i}" for i in range(12)]


# ===========================================================================
# BashTool: Resource limit enforcement
# ===========================================================================


def test_max_loop_iterations_prevents_infinite_loop():
"""max_loop_iterations stops infinite loops."""
tool = BashTool(max_loop_iterations=10)
r = tool.execute_sync("i=0; while true; do i=$((i+1)); done; echo $i")
# Should stop before completing — either error or truncated output
assert r.exit_code != 0 or int(r.stdout.strip() or "0") <= 100


def test_max_commands_limits_execution():
"""max_commands stops after N commands."""
tool = BashTool(max_commands=5)
r = tool.execute_sync("echo 1; echo 2; echo 3; echo 4; echo 5; echo 6; echo 7; echo 8; echo 9; echo 10")
# Should stop before all 10 commands complete
lines = [line for line in r.stdout.strip().splitlines() if line]
assert len(lines) < 10 or r.exit_code != 0


# ===========================================================================
# BashTool: Error conditions
# ===========================================================================


def test_malformed_bash_syntax():
"""Unclosed quotes produce an error."""
tool = BashTool()
r = tool.execute_sync('echo "unclosed')
# Should fail with parse error
assert r.exit_code != 0 or r.error is not None


def test_nonexistent_command():
"""Unknown commands return exit code 127."""
tool = BashTool()
r = tool.execute_sync("nonexistent_xyz_cmd_12345")
assert r.exit_code == 127


def test_large_output():
"""Large output is handled without crash."""
tool = BashTool()
r = tool.execute_sync("for i in $(seq 1 1000); do echo line$i; done")
assert r.exit_code == 0
lines = r.stdout.strip().splitlines()
assert len(lines) == 1000


def test_empty_input():
"""Empty script returns success."""
tool = BashTool()
r = tool.execute_sync("")
assert r.exit_code == 0
assert r.stdout == ""


# ===========================================================================
# ScriptedTool: Edge cases
# ===========================================================================


def test_scripted_tool_callback_runtime_error():
"""RuntimeError in callback is caught."""
tool = ScriptedTool("api")
tool.add_tool(
"fail",
"Fails with RuntimeError",
callback=lambda p, s=None: (_ for _ in ()).throw(RuntimeError("runtime fail")),
)
r = tool.execute_sync("fail")
assert r.exit_code != 0
assert "runtime fail" in r.stderr


def test_scripted_tool_callback_type_error():
"""TypeError in callback is caught."""
tool = ScriptedTool("api")
tool.add_tool(
"bad",
"Fails with TypeError",
callback=lambda p, s=None: (_ for _ in ()).throw(TypeError("bad type")),
)
r = tool.execute_sync("bad")
assert r.exit_code != 0


def test_scripted_tool_large_callback_output():
"""Callbacks returning large output work."""
tool = ScriptedTool("api")
tool.add_tool(
"big",
"Returns large output",
callback=lambda p, s=None: "x" * 10000 + "\n",
)
r = tool.execute_sync("big")
assert r.exit_code == 0
assert len(r.stdout.strip()) == 10000


def test_scripted_tool_callback_returns_empty():
"""Callback returning empty string is ok."""
tool = ScriptedTool("api")
tool.add_tool(
"empty",
"Returns nothing",
callback=lambda p, s=None: "",
)
r = tool.execute_sync("empty")
assert r.exit_code == 0


@pytest.mark.asyncio
async def test_async_multiple_tools():
"""Multiple async calls to different tools work."""
tool = ScriptedTool("api")
tool.add_tool("a", "Tool A", callback=lambda p, s=None: "A\n")
tool.add_tool("b", "Tool B", callback=lambda p, s=None: "B\n")
r = await tool.execute("a; b")
assert r.exit_code == 0
assert "A" in r.stdout
assert "B" in r.stdout
122 changes: 122 additions & 0 deletions crates/bashkit-python/tests/test_frameworks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Tests for framework integration modules (langchain, deepagents, pydantic_ai).

These tests verify the integration modules work without the external frameworks
by testing the import-guarding, factory functions, and mock behavior.
"""

import pytest

from bashkit import ScriptedTool

# ===========================================================================
# langchain.py tests
# ===========================================================================


def test_langchain_import():
"""langchain module imports without langchain installed."""
from bashkit import langchain # noqa: F401


def test_langchain_create_bash_tool_without_langchain():
"""create_bash_tool raises ImportError when langchain not installed."""
from bashkit.langchain import LANGCHAIN_AVAILABLE, create_bash_tool

if not LANGCHAIN_AVAILABLE:
with pytest.raises(ImportError, match="langchain-core"):
create_bash_tool()


def test_langchain_create_scripted_tool_without_langchain():
"""create_scripted_tool raises ImportError when langchain not installed."""
from bashkit.langchain import LANGCHAIN_AVAILABLE, create_scripted_tool

if not LANGCHAIN_AVAILABLE:
st = ScriptedTool("api")
st.add_tool("noop", "No-op", callback=lambda p, s=None: "ok\n")
with pytest.raises(ImportError, match="langchain-core"):
create_scripted_tool(st)


def test_langchain_all_exports():
"""langchain __all__ contains expected symbols."""
from bashkit.langchain import __all__

assert "create_bash_tool" in __all__
assert "create_scripted_tool" in __all__
assert "BashkitTool" in __all__
assert "BashToolInput" in __all__


# ===========================================================================
# deepagents.py tests
# ===========================================================================


def test_deepagents_import():
"""deepagents module imports without deepagents installed."""
from bashkit import deepagents # noqa: F401


def test_deepagents_create_bash_middleware_without_deepagents():
"""create_bash_middleware raises ImportError when deepagents not installed."""
from bashkit.deepagents import DEEPAGENTS_AVAILABLE, create_bash_middleware

if not DEEPAGENTS_AVAILABLE:
with pytest.raises(ImportError, match="deepagents"):
create_bash_middleware()


def test_deepagents_create_bashkit_backend_without_deepagents():
"""create_bashkit_backend raises ImportError when deepagents not installed."""
from bashkit.deepagents import DEEPAGENTS_AVAILABLE, create_bashkit_backend

if not DEEPAGENTS_AVAILABLE:
with pytest.raises(ImportError, match="deepagents"):
create_bashkit_backend()


def test_deepagents_all_exports():
"""deepagents __all__ contains expected symbols."""
from bashkit.deepagents import __all__

assert "create_bash_middleware" in __all__
assert "create_bashkit_backend" in __all__
assert "BashkitMiddleware" in __all__
assert "BashkitBackend" in __all__


def test_deepagents_now_iso():
"""_now_iso returns ISO format string."""
from bashkit.deepagents import _now_iso

ts = _now_iso()
assert isinstance(ts, str)
assert "T" in ts # ISO format has T separator


# ===========================================================================
# pydantic_ai.py tests
# ===========================================================================


def test_pydantic_ai_import():
"""pydantic_ai module imports without pydantic-ai installed."""
from bashkit import pydantic_ai # noqa: F401


def test_pydantic_ai_create_bash_tool_without_pydantic():
"""create_bash_tool raises ImportError when pydantic-ai not installed."""
from bashkit.pydantic_ai import PYDANTIC_AI_AVAILABLE
from bashkit.pydantic_ai import create_bash_tool as create_pydantic_tool

if not PYDANTIC_AI_AVAILABLE:
with pytest.raises(ImportError, match="pydantic-ai"):
create_pydantic_tool()


def test_pydantic_ai_all_exports():
"""pydantic_ai __all__ contains expected symbols."""
from bashkit.pydantic_ai import __all__

assert "create_bash_tool" in __all__
Loading
Loading