Skip to content

Commit ab9c15f

Browse files
authored
add tests + coverage to ci (#3)
1 parent 55e865a commit ab9c15f

File tree

9 files changed

+238
-23
lines changed

9 files changed

+238
-23
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
name: CI
22
on:
33
push:
4+
branches:
5+
- main
46
pull_request:
7+
branches:
8+
- main
59

610
jobs:
711
typecheck:
@@ -27,3 +31,20 @@ jobs:
2731
python-version: "3.13"
2832
- uses: pypa/hatch@install
2933
- run: hatch fmt --check
34+
test:
35+
name: Test
36+
runs-on: ubuntu-22.04
37+
strategy:
38+
matrix:
39+
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
40+
steps:
41+
- uses: actions/checkout@v4
42+
- uses: actions/setup-python@v5
43+
with:
44+
python-version: ${{ matrix.python }}
45+
- uses: pypa/hatch@install
46+
# TODO: remove after we release codex-sdk package (and are no longer installing from github)
47+
- name: setup git url rewrite
48+
run: git config --global url."https://${{ secrets.GH_USERNAME }}:${{ secrets.CLEANLAB_BOT_PAT }}@github.com".insteadOf ssh://[email protected]
49+
- run: hatch test -v --cover --include python=$(echo ${{ matrix.python }})
50+
- run: hatch run coverage:report

pyproject.toml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,22 @@ check = "mypy --install-types --non-interactive {args:src/cleanlab_codex tests}"
4848
[tool.hatch.metadata]
4949
allow-direct-references = true
5050

51+
[tool.hatch.envs.hatch-test]
52+
extra-dependencies = [
53+
"llama-index-core",
54+
]
55+
5156
[tool.coverage.run]
5257
source_pkgs = ["cleanlab_codex", "tests"]
5358
branch = true
5459
parallel = true
5560
omit = [
5661
"src/cleanlab_codex/__about__.py",
62+
"*/tests/*",
5763
]
5864

5965
[tool.coverage.paths]
6066
cleanlab_codex = ["src/cleanlab_codex", "*/cleanlab-codex/src/cleanlab_codex"]
61-
tests = ["tests", "*/cleanlab-codex/tests"]
6267

6368
[tool.coverage.report]
6469
exclude_lines = [
@@ -67,5 +72,16 @@ exclude_lines = [
6772
"if TYPE_CHECKING:",
6873
]
6974

75+
[tool.hatch.envs.coverage]
76+
detached = true
77+
dependencies = [
78+
"coverage",
79+
]
80+
81+
[tool.hatch.envs.coverage.scripts]
82+
report = "coverage report --fail-under=90"
83+
html = "coverage html"
84+
xml = "coverage xml"
85+
7086
[tool.ruff.lint]
71-
ignore = ["FA100", "UP007"]
87+
ignore = ["FA100", "UP007", "UP006"]

src/cleanlab_codex/utils/openai.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Literal
3+
from typing import Any, Dict, List, Literal
44

55
from pydantic import BaseModel
66

@@ -12,8 +12,8 @@ class Property(BaseModel):
1212

1313
class FunctionParameters(BaseModel):
1414
type: Literal["object"] = "object"
15-
properties: dict[str, Property]
16-
required: list[str]
15+
properties: Dict[str, Property]
16+
required: List[str]
1717

1818

1919
class Function(BaseModel):
@@ -30,9 +30,9 @@ class Tool(BaseModel):
3030
def format_as_openai_tool(
3131
tool_name: str,
3232
tool_description: str,
33-
tool_properties: dict[str, Any],
34-
required_properties: list[str],
35-
) -> dict[str, Any]:
33+
tool_properties: Dict[str, Any],
34+
required_properties: List[str],
35+
) -> Dict[str, Any]:
3636
return Tool(
3737
function=Function(
3838
name=tool_name,

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from tests.fixtures.client import mock_client
2+
3+
__all__ = ["mock_client"]

tests/fixtures/__init__.py

Whitespace-only changes.

tests/fixtures/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Generator
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def mock_client() -> Generator[MagicMock, None, None]:
9+
with patch("cleanlab_codex.codex.init_codex_client") as mock_init:
10+
mock_client = MagicMock()
11+
mock_init.return_value = mock_client
12+
yield mock_client

tests/internal/test_utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from cleanlab_codex.internal.utils import is_access_key
1+
from unittest.mock import patch
2+
3+
from cleanlab_codex.internal.utils import init_codex_client, is_access_key
24

35
DUMMY_ACCESS_KEY = "sk-1-EMOh6UrRo7exTEbEi8_azzACAEdtNiib2LLa1IGo6kA"
46
DUMMY_API_KEY = "GP0FzPfA7wYy5L64luII2YaRT2JoSXkae7WEo7dH6Bw"
@@ -7,3 +9,17 @@
79
def test_is_access_key():
810
assert is_access_key(DUMMY_ACCESS_KEY)
911
assert not is_access_key(DUMMY_API_KEY)
12+
13+
14+
def test_init_codex_client_access_key():
15+
with patch("cleanlab_codex.internal.utils._Codex", autospec=True) as mock_codex:
16+
client = init_codex_client(DUMMY_ACCESS_KEY)
17+
mock_codex.assert_called_once_with(access_key=DUMMY_ACCESS_KEY)
18+
assert client is not None
19+
20+
21+
def test_init_codex_client_api_key():
22+
with patch("cleanlab_codex.internal.utils._Codex", autospec=True) as mock_codex:
23+
client = init_codex_client(DUMMY_API_KEY)
24+
mock_codex.assert_called_once_with(api_key=DUMMY_API_KEY)
25+
assert client is not None

tests/test_codex.py

Lines changed: 138 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,150 @@
1-
from typing import Generator
2-
from unittest.mock import MagicMock, patch
1+
# ruff: noqa: DTZ005
2+
3+
import uuid
4+
from datetime import datetime
5+
from unittest.mock import MagicMock
36

47
import pytest
5-
from codex import Codex as _Codex
8+
from codex.types.project_return_schema import Config, ProjectReturnSchema
9+
from codex.types.users.myself.user_organizations_schema import UserOrganizationsSchema
610

711
from cleanlab_codex.codex import Codex
12+
from cleanlab_codex.internal.project import MissingProjectIdError
13+
from cleanlab_codex.types.entry import Entry, EntryCreate
14+
from cleanlab_codex.types.organization import Organization
15+
from cleanlab_codex.types.project import ProjectConfig
16+
17+
FAKE_PROJECT_ID = 1
18+
FAKE_USER_ID = "Test User"
19+
FAKE_ORGANIZATION_ID = "Test Organization"
20+
FAKE_PROJECT_NAME = "Test Project"
21+
FAKE_PROJECT_DESCRIPTION = "Test Description"
22+
DEFAULT_PROJECT_CONFIG = ProjectConfig()
23+
24+
25+
def test_list_organizations(mock_client: MagicMock):
26+
mock_client.users.myself.organizations.list.return_value = UserOrganizationsSchema(
27+
organizations=[
28+
Organization(
29+
organization_id=FAKE_ORGANIZATION_ID,
30+
created_at=datetime.now(),
31+
updated_at=datetime.now(),
32+
user_id=FAKE_USER_ID,
33+
)
34+
],
35+
)
36+
codex = Codex("")
37+
organizations = codex.list_organizations()
38+
assert len(organizations) == 1
39+
assert organizations[0].organization_id == FAKE_ORGANIZATION_ID
40+
assert organizations[0].user_id == FAKE_USER_ID
41+
42+
43+
def test_create_project(mock_client: MagicMock):
44+
mock_client.projects.create.return_value = ProjectReturnSchema(
45+
id=FAKE_PROJECT_ID,
46+
config=Config(),
47+
created_at=datetime.now(),
48+
created_by_user_id=FAKE_USER_ID,
49+
name=FAKE_PROJECT_NAME,
50+
organization_id=FAKE_ORGANIZATION_ID,
51+
updated_at=datetime.now(),
52+
description=FAKE_PROJECT_DESCRIPTION,
53+
)
54+
codex = Codex("")
55+
project_id = codex.create_project(FAKE_PROJECT_NAME, FAKE_ORGANIZATION_ID, FAKE_PROJECT_DESCRIPTION)
56+
mock_client.projects.create.assert_called_once_with(
57+
config=DEFAULT_PROJECT_CONFIG,
58+
organization_id=FAKE_ORGANIZATION_ID,
59+
name=FAKE_PROJECT_NAME,
60+
description=FAKE_PROJECT_DESCRIPTION,
61+
)
62+
assert project_id == FAKE_PROJECT_ID
863

9-
fake_project_id = 1
1064

65+
def test_add_entries(mock_client: MagicMock):
66+
answered_entry_create = EntryCreate(
67+
question="What is the capital of France?",
68+
answer="Paris",
69+
)
70+
unanswered_entry_create = EntryCreate(
71+
question="What is the capital of Germany?",
72+
)
73+
codex = Codex("")
74+
codex.add_entries([answered_entry_create, unanswered_entry_create], project_id=FAKE_PROJECT_ID)
1175

12-
@pytest.fixture
13-
def mock_client() -> Generator[_Codex, None, None]:
14-
with patch("cleanlab_codex.codex.init_codex_client", return_value=MagicMock()) as mock:
15-
yield mock
76+
for call, entry in zip(
77+
mock_client.projects.entries.create.call_args_list,
78+
[answered_entry_create, unanswered_entry_create],
79+
):
80+
assert call.args[0] == FAKE_PROJECT_ID
81+
assert call.kwargs["question"] == entry["question"]
82+
assert call.kwargs["answer"] == entry.get("answer")
1683

1784

18-
def test_query_read_only(mock_client: _Codex):
19-
mock_client.projects.entries.query.return_value = None # type: ignore
85+
def test_create_project_access_key(mock_client: MagicMock):
2086
codex = Codex("")
21-
res = codex.query("What is the capital of France?", read_only=True, project_id=fake_project_id)
22-
mock_client.projects.entries.query.assert_called_once_with( # type: ignore
23-
fake_project_id, "What is the capital of France?"
87+
access_key_name = "Test Access Key"
88+
access_key_description = "Test Access Key Description"
89+
codex.create_project_access_key(FAKE_PROJECT_ID, access_key_name, access_key_description)
90+
mock_client.projects.access_keys.create.assert_called_once_with(
91+
project_id=FAKE_PROJECT_ID,
92+
name=access_key_name,
93+
description=access_key_description,
2494
)
25-
mock_client.projects.entries.add_question.assert_not_called() # type: ignore
95+
96+
97+
def test_query_no_project_id(mock_client: MagicMock):
98+
mock_client.access_key = None
99+
codex = Codex("")
100+
101+
with pytest.raises(MissingProjectIdError):
102+
codex.query("What is the capital of France?")
103+
104+
105+
def test_query_read_only(mock_client: MagicMock):
106+
mock_client.access_key = None
107+
mock_client.projects.entries.query.return_value = None
108+
109+
codex = Codex("")
110+
res = codex.query("What is the capital of France?", read_only=True, project_id=FAKE_PROJECT_ID)
111+
mock_client.projects.entries.query.assert_called_once_with(
112+
FAKE_PROJECT_ID, question="What is the capital of France?"
113+
)
114+
mock_client.projects.entries.add_question.assert_not_called()
26115
assert res == (None, None)
116+
117+
118+
def test_query_question_found_fallback_answer(mock_client: MagicMock):
119+
unanswered_entry = Entry(
120+
id=str(uuid.uuid4()),
121+
created_at=datetime.now(),
122+
question="What is the capital of France?",
123+
answer=None,
124+
)
125+
mock_client.projects.entries.query.return_value = unanswered_entry
126+
codex = Codex("")
127+
res = codex.query("What is the capital of France?", project_id=FAKE_PROJECT_ID)
128+
assert res == (None, unanswered_entry)
129+
130+
131+
def test_query_question_not_found_fallback_answer(mock_client: MagicMock):
132+
mock_client.projects.entries.query.return_value = None
133+
mock_client.projects.entries.add_question.return_value = None
134+
135+
codex = Codex("")
136+
res = codex.query("What is the capital of France?", fallback_answer="Paris")
137+
assert res == ("Paris", None)
138+
139+
140+
def test_query_answer_found(mock_client: MagicMock):
141+
answered_entry = Entry(
142+
id=str(uuid.uuid4()),
143+
created_at=datetime.now(),
144+
question="What is the capital of France?",
145+
answer="Paris",
146+
)
147+
mock_client.projects.entries.query.return_value = answered_entry
148+
codex = Codex("")
149+
res = codex.query("What is the capital of France?", project_id=FAKE_PROJECT_ID)
150+
assert res == ("Paris", answered_entry)

tests/test_codex_tool.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from unittest.mock import MagicMock
2+
3+
from llama_index.core.tools import FunctionTool
4+
5+
from cleanlab_codex.codex_tool import CodexTool
6+
7+
8+
def test_to_openai_tool(mock_client: MagicMock): # noqa: ARG001
9+
tool = CodexTool.from_access_key("")
10+
openai_tool = tool.to_openai_tool()
11+
assert openai_tool.get("type") == "function"
12+
assert openai_tool.get("function", {}).get("name") == tool.tool_name
13+
assert openai_tool.get("function", {}).get("description") == tool.tool_description
14+
assert openai_tool.get("function", {}).get("parameters", {}).get("type") == "object"
15+
16+
17+
def test_to_llamaindex_tool(mock_client: MagicMock): # noqa: ARG001
18+
tool = CodexTool.from_access_key("")
19+
llama_index_tool = tool.to_llamaindex_tool()
20+
assert isinstance(llama_index_tool, FunctionTool)
21+
assert llama_index_tool.metadata.name == tool.tool_name
22+
assert llama_index_tool.metadata.description == tool.tool_description
23+
assert llama_index_tool.fn == tool.query

0 commit comments

Comments
 (0)