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
39 changes: 39 additions & 0 deletions .claudeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Python-specific ignores
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
.eggs/

# Virtual environments
.venv/
venv/
ENV/
env/

# Testing & coverage
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
.coverage.*
.tox/
noxfile.py
.nox/

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# Git
.git/
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-cov>=5.0",
"pytest-timeout>=2.0",
]

[project.scripts]
Expand Down Expand Up @@ -76,6 +77,10 @@ markers = [
"network: tests that require network access (deselect with -m 'not network')",
"skipif: conditional skip",
]
filterwarnings = [
# nmrglue uses deprecated NumPy 2.0 dtype aliases (fixed in their repo, pending release)
"ignore::DeprecationWarning:nmrglue.*",
]

[tool.coverage.run]
source = ["device_use"]
Expand Down
4 changes: 3 additions & 1 deletion src/device_use/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ def _scaffold(device_name: str, output_dir: str):
class_name = "".join(
w.capitalize() for w in device_name.replace("-", " ").replace("_", " ").split()
)
# Extract vendor from device name (first part before dash/underscore)
vendor = device_name.split("-")[0].split("_")[0].capitalize()
root = os.path.join(output_dir, pkg_name)

if os.path.exists(root):
Expand Down Expand Up @@ -337,7 +339,7 @@ def __init__(self, mode: ControlMode = ControlMode.OFFLINE):
def info(self) -> InstrumentInfo:
return InstrumentInfo(
name="{class_name}",
vendor="TODO",
vendor="{vendor}",
instrument_type="{slug}",
supported_modes=[ControlMode.OFFLINE, ControlMode.API, ControlMode.GUI],
version="0.1.0",
Expand Down
21 changes: 21 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,27 @@ def test_scaffold_existing_dir(self, tmp_path, capsys):
out = capsys.readouterr().out
assert "already exists" in out

def test_scaffold_vendor_extraction(self, tmp_path):
"""Vendor should be extracted from device name (first part before dash)."""
cli._scaffold("biotek-gen5", str(tmp_path))
adapter_file = (
tmp_path / "device_use_biotek_gen5" / "src" / "device_use_biotek_gen5" / "adapter.py"
)
content = adapter_file.read_text()
assert 'vendor="Biotek"' in content
# Ensure vendor field doesn't have the placeholder
assert 'vendor="TODO"' not in content

def test_scaffold_vendor_single_word(self, tmp_path):
"""Vendor should be capitalized for single-word device names."""
cli._scaffold("mydevice", str(tmp_path))
adapter_file = (
tmp_path / "device_use_mydevice" / "src" / "device_use_mydevice" / "adapter.py"
)
content = adapter_file.read_text()
assert 'vendor="Mydevice"' in content
assert 'vendor="TODO"' not in content


# ---------------------------------------------------------------------------
# _write
Expand Down
9 changes: 4 additions & 5 deletions tests/test_converter_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@


class TestResolveRedirect:
def test_url_without_enUS_pattern_returns_none(self, tmp_path):
def test_url_without_en_us_pattern_returns_none(self, tmp_path):
"""Line 182: redirect URL that doesn't match the en-US regex."""
stub = tmp_path / "stub.html"
stub.write_text('<meta http-equiv="refresh" content="0;url=/some/other/path/page.html">')
Expand Down Expand Up @@ -247,7 +247,7 @@ def test_description_fallback_summary(self, tmp_path):
assert result["summary"] == "This is a detailed description for the command page."
assert result["commands"] == []

def test_convert_missing_enUS_dir(self, tmp_path):
def test_convert_missing_en_us_dir(self, tmp_path):
"""convert_topspin_command returns None when en-US dir is missing."""
stub = tmp_path / "missing.html"
stub.write_text(
Expand Down Expand Up @@ -291,7 +291,6 @@ def test_exception_in_convert_is_caught(self, tmp_path, capsys):
pass

# More direct approach: patch convert_topspin_command itself
original = convert_topspin_command
with patch(
"device_use.knowledge.converter.convert_topspin_command",
side_effect=RuntimeError("boom"),
Expand Down Expand Up @@ -589,7 +588,7 @@ def test_single_word_no_dash(self):


class TestRedirectParserEdgeCases:
def test_uppercase_REFRESH(self):
def test_uppercase_refresh(self):
"""http-equiv='REFRESH' (uppercase) is matched case-insensitively."""
html = (
'<meta http-equiv="REFRESH"'
Expand Down Expand Up @@ -774,7 +773,7 @@ def test_main_with_index_output_flag(self, tmp_path, capsys):
):
main()

captured = capsys.readouterr()
capsys.readouterr()
assert custom_index.exists()

def test_main_no_entries_no_index(self, tmp_path, capsys):
Expand Down
33 changes: 16 additions & 17 deletions tests/test_openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
@pytest.fixture
def mock_async_openai():
"""Fixture to mock AsyncOpenAI client."""
with patch("device_use.backends.openai_compat.AsyncOpenAI") as MockAsyncOpenAI:
mock_client = MockAsyncOpenAI.return_value
yield MockAsyncOpenAI, mock_client
with patch("device_use.backends.openai_compat.AsyncOpenAI") as mock_openai_cls:
mock_client = mock_openai_cls.return_value
yield mock_openai_cls, mock_client


class TestSupportsComputerUse:
Expand Down Expand Up @@ -55,8 +55,8 @@ class TestOpenAICompatBackendInitialization:
],
)
def test_initialization(self, mock_async_openai, model, expected_native_cu):
MockAsyncOpenAI_class, mock_async_openai_instance = mock_async_openai
MockAsyncOpenAI_class.reset_mock()
mock_openai_cls, mock_async_openai_instance = mock_async_openai
mock_openai_cls.reset_mock()
backend = OpenAICompatBackend(model=model, api_key="test_key", base_url="http://test.url")

assert backend._model == model
Expand All @@ -66,19 +66,17 @@ def test_initialization(self, mock_async_openai, model, expected_native_cu):
assert backend.system_prompt == ""
assert backend._previous_response_id is None

MockAsyncOpenAI_class.assert_called_once_with(
api_key="test_key", base_url="http://test.url"
)
mock_openai_cls.assert_called_once_with(api_key="test_key", base_url="http://test.url")

def test_default_values(self, mock_async_openai):
MockAsyncOpenAI_class, mock_async_openai_instance = mock_async_openai
mock_openai_cls, mock_async_openai_instance = mock_async_openai
backend = OpenAICompatBackend(api_key="test_key") # Add api_key here
assert backend._model == "gpt-5.4"
assert backend._max_tokens == 4096
assert backend._native_cu is True # gpt-5.4 is default

def test_supports_grounding_property(self, mock_async_openai):
MockAsyncOpenAI_class, mock_async_openai_instance = mock_async_openai
mock_openai_cls, mock_async_openai_instance = mock_async_openai
cu_backend = OpenAICompatBackend(model="gpt-5.4", api_key="test_key")
assert cu_backend.supports_grounding is True

Expand All @@ -87,7 +85,7 @@ def test_supports_grounding_property(self, mock_async_openai):

@pytest.fixture(autouse=True)
def setup(self, mock_async_openai):
MockAsyncOpenAI_class, self.mock_client = mock_async_openai
mock_openai_cls, self.mock_client = mock_async_openai
self.cu_backend = OpenAICompatBackend(model="gpt-5.4", api_key="test_key")
self.mock_responses_create = AsyncMock()
self.mock_client.responses.create = self.mock_responses_create
Expand Down Expand Up @@ -272,7 +270,7 @@ class TestMapCUAction:

@pytest.fixture(autouse=True)
def setup(self, mock_async_openai):
MockAsyncOpenAI_class, mock_client = (
mock_openai_cls, mock_client = (
mock_async_openai # unpack here, not used directly in this fixture, but good practice
)
self.backend = OpenAICompatBackend(model="gpt-5.4", api_key="test_key")
Expand Down Expand Up @@ -633,7 +631,7 @@ class TestPlanObserveLocate:

@pytest.fixture(autouse=True)
def setup(self, mock_async_openai):
MockAsyncOpenAI_class, self.mock_client = mock_async_openai
mock_openai_cls, self.mock_client = mock_async_openai
self.cu_backend = OpenAICompatBackend(model="gpt-5.4", api_key="test_key")
self.legacy_backend = OpenAICompatBackend(model="gpt-4o", api_key="test_key")
self.mock_responses_create = AsyncMock()
Expand Down Expand Up @@ -813,9 +811,10 @@ async def test_plan_native_remaining_actions(self):

async def test_plan_legacy(self):
mock_choice = MagicMock()
mock_choice.message.content = """
{"action": {"action_type": "type", "text": "hello"}, "reasoning": "type text", "done": false, "confidence": 0.8}
"""
mock_choice.message.content = (
'{"action": {"action_type": "type", "text": "hello"},'
' "reasoning": "type text", "done": false, "confidence": 0.8}'
)
self.mock_chat_completions_create.return_value = MagicMock(choices=[mock_choice])

result = await self.legacy_backend.plan(self.screenshot_bytes, "task", history=[])
Expand Down Expand Up @@ -865,7 +864,7 @@ class TestLegacyChatCompletions:

@pytest.fixture(autouse=True)
def setup(self, mock_async_openai):
MockAsyncOpenAI_class, self.mock_client = mock_async_openai
mock_openai_cls, self.mock_client = mock_async_openai
self.legacy_backend = OpenAICompatBackend(model="gpt-4o", api_key="test_key")
self.mock_chat_completions_create = AsyncMock()
self.mock_client.chat.completions.create = self.mock_chat_completions_create
Expand Down
2 changes: 1 addition & 1 deletion tests/test_spectral_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ def test_from_examdata_skips_no_fid(self, tmp_path):
expno_dir.mkdir()
# No fid file

mock_processor = MagicMock()
MagicMock() # processor not directly needed
lib = SpectralLibrary(tolerance_ppm=0.05)
for sample_dir_child in sorted(examdata.iterdir()):
if not sample_dir_child.is_dir() or sample_dir_child.name.startswith("."):
Expand Down
8 changes: 4 additions & 4 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ def test_analyze_stream_sse_with_formula(self, mock_get_adapter, client):

class TestPubChemEndpoint:
def test_pubchem_lookup_success(self, client):
with patch("device_use.tools.pubchem.PubChemTool") as MockTool:
mock_tool = MockTool.return_value
with patch("device_use.tools.pubchem.PubChemTool") as mock_tool_cls:
mock_tool = mock_tool_cls.return_value
mock_tool.lookup_by_name.return_value = {
"CID": 1234,
"IUPACName": "test-name",
Expand All @@ -373,8 +373,8 @@ def test_pubchem_lookup_success(self, client):
def test_pubchem_lookup_not_found(self, client):
from device_use.tools.pubchem import PubChemError

with patch("device_use.tools.pubchem.PubChemTool") as MockTool:
mock_tool = MockTool.return_value
with patch("device_use.tools.pubchem.PubChemTool") as mock_tool_cls:
mock_tool = mock_tool_cls.return_value
mock_tool.lookup_by_name.side_effect = PubChemError("Not found")
res = client.get("/api/pubchem/nonexistent_xyz")
assert res.status_code == 404
Expand Down