Skip to content

Commit 951c5b4

Browse files
committed
Add runtime type checking
This patch adds beartype for runtime type checking. This gives us the best of both worlds: we do static type checking of our own library with mypy, and we export our static types, but for clients who do not run static type checking of their own code, runtime type checking in our library can help them catch bugs earlier. `tests/test_codex_tool.py::test_bad_argument_type` serves as an example: this fails at initialization time of `CodexTool`, whereas without runtime type checking, this would fail later (e.g., when the user calls the `query` method on the object). Because we're performing runtime type checking, some of the imports that were behind `if TYPE_CHECKING` flags have to be moved to runtime. This patch updates the linter config to allow imports that are only used for type checking. This patch also switches to consistent `from __future__ import annotations` everywhere, stops using type hints deprecated by PEP 585 by using `beartype.typing` instead, and updates the linter config accordingly. This patch also updates the CI config to run static type checking for all supported Python versions. beartype relies on `isinstance` for runtime type checks, which needs to be taken into account when using mocks by overriding the `__class__` attribute. This patch updates the tests accordingly.
1 parent 453263b commit 951c5b4

File tree

16 files changed

+114
-43
lines changed

16 files changed

+114
-43
lines changed

.editorconfig

+3
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ indent_size = 4
1212

1313
[*.toml]
1414
indent_size = 2
15+
16+
[*.md]
17+
indent_size = 4

.github/workflows/ci.yml

+6-3
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ on:
1010

1111
jobs:
1212
typecheck:
13-
name: Type check
13+
name: "Type check: Python ${{ matrix.python }}"
1414
runs-on: ubuntu-24.04
15+
strategy:
16+
matrix:
17+
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
1518
steps:
1619
- uses: actions/checkout@v4
1720
- uses: actions/setup-python@v5
1821
with:
19-
python-version: "3.13"
22+
python-version: ${{ matrix.python }}
2023
- uses: pypa/hatch@install
2124
- run: hatch run types:check
2225
fmt:
@@ -30,7 +33,7 @@ jobs:
3033
- uses: pypa/hatch@install
3134
- run: hatch fmt --check
3235
test:
33-
name: Test
36+
name: "Test: Python ${{ matrix.python }}"
3437
runs-on: ubuntu-22.04
3538
strategy:
3639
matrix:

DEVELOPMENT.md

+32-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Development
22

3+
## Guidelines
4+
5+
### Typing
6+
7+
This project uses [mypy][mypy] for static type checking as well as [beartype][beartype] for runtime type checking.
8+
9+
The combination of using beartype and supporting Python 3.8+ leads to some [challenges][beartype-pep585] related to [PEP 585][pep-585] deprecations. For this reason, this package:
10+
11+
- Imports from `beartype.typing` all types that are deprecated in PEP 585 (e.g., `List` and `Callable`)
12+
- Imports directly from `typing` all other types (e.g., `Optional` and `Literal`)
13+
- These symbols are also available in `beartype.typing`, but we import them directly from `typing` because Ruff has special treatment of these imports. For example, Ruff will complain about `Literal["foo"]` if we import `Literal` from `beartype.typing`.
14+
15+
Relatedly, this package also cannot use [PEP 604][pep-604] syntax:
16+
17+
- Instead of using types like `A | B`, use `Union[A, B]`
18+
- Instead of using types like `A | None`, use `Optional[A]`
19+
20+
[mypy]: https://mypy-lang.org/
21+
[beartype]: https://github.com/beartype/beartype
22+
[beartype-pep585]: https://beartype.readthedocs.io/en/latest/api_roar/#pep-585-deprecations
23+
[pep-585]: https://peps.python.org/pep-0585/
24+
[pep-604]: https://peps.python.org/pep-0604/
25+
26+
## Tooling
27+
328
This project uses the [Hatch] project manager ([installation instructions][hatch-install]).
429

530
Hatch automatically manages dependencies and runs testing, type checking, and other operations in isolated [environments][hatch-environments].
@@ -8,7 +33,7 @@ Hatch automatically manages dependencies and runs testing, type checking, and ot
833
[hatch-install]: https://hatch.pypa.io/latest/install/
934
[hatch-environments]: https://hatch.pypa.io/latest/environment/
1035

11-
## Testing
36+
### Testing
1237

1338
You can run the tests on your local machine with:
1439

@@ -20,17 +45,15 @@ The [`test` command][hatch-test] supports options such as `-c` for measuring tes
2045

2146
[hatch-test]: https://hatch.pypa.io/latest/tutorials/testing/overview/
2247

23-
## Type checking
48+
### Type checking
2449

2550
You can run the [mypy static type checker][mypy] with:
2651

2752
```bash
2853
hatch run types:check
2954
```
3055

31-
[mypy]: https://mypy-lang.org/
32-
33-
## Formatting and linting
56+
### Formatting and linting
3457

3558
You can run the [Ruff][ruff] formatter and linter with:
3659

@@ -43,7 +66,7 @@ This will automatically make [safe fixes][fix-safety] to your code. If you want
4366
[ruff]: https://github.com/astral-sh/ruff
4467
[fix-safety]: https://docs.astral.sh/ruff/linter/#fix-safety
4568

46-
## Pre-commit
69+
### Pre-commit
4770

4871
You can install the pre-commit hooks to automatically run type checking, formatting, and linting on every commit.
4972

@@ -61,7 +84,7 @@ pre-commit install
6184

6285
[pipx]: https://pipx.pypa.io/
6386

64-
## Packaging
87+
### Packaging
6588

6689
You can use [`hatch build`][hatch-build] to create build artifacts, a [source distribution ("sdist")][sdist] and a [built distribution ("wheel")][bdist].
6790

@@ -73,7 +96,7 @@ You can use [`hatch publish`][hatch-publish] if you want to manually publish bui
7396
[hatch-publish]: https://hatch.pypa.io/latest/publish/
7497
[pypi]: https://pypi.org/
7598

76-
### Automated releases
99+
#### Automated releases
77100

78101
Automated releases are handled by the [release workflow][release-workflow] which is triggered by pushing a new tag to the repository. To create a new release:
79102

@@ -88,7 +111,7 @@ Automated releases are handled by the [release workflow][release-workflow] which
88111
[hatch-version]: https://hatch.pypa.io/latest/version/#updating
89112
[changelog]: CHANGELOG.md
90113

91-
## Continuous integration
114+
### Continuous integration
92115

93116
Testing, type checking, and formatting/linting is [checked in CI][ci].
94117

pyproject.toml

+7-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
dependencies = [
2828
"codex-sdk==0.1.0a9",
2929
"pydantic>=1.9.0, <3",
30+
"beartype>=0.17.0",
3031
]
3132

3233
[project.urls]
@@ -42,7 +43,6 @@ extra-dependencies = [
4243
"mypy>=1.0.0",
4344
"pytest",
4445
"llama-index-core",
45-
"smolagents",
4646
]
4747
[tool.hatch.envs.types.scripts]
4848
check = "mypy --strict --install-types --non-interactive {args:src/cleanlab_codex tests}"
@@ -98,4 +98,9 @@ html = "coverage html"
9898
xml = "coverage xml"
9999

100100
[tool.ruff.lint]
101-
ignore = ["FA100", "UP007", "UP006"]
101+
ignore = [
102+
"TCH001", # this package does runtime type checking
103+
"TCH002",
104+
"TCH003",
105+
"UP007", # we cannot use the PEP 604 syntax because we support Python 3.8 and do runtime type checking
106+
]

src/cleanlab_codex/__init__.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
# SPDX-License-Identifier: MIT
2+
3+
from beartype.claw import beartype_this_package
4+
5+
# this must run before any other imports from the cleanlab_codex package
6+
beartype_this_package()
7+
8+
# ruff: noqa: E402
29
from cleanlab_codex.codex import Codex
310
from cleanlab_codex.codex_tool import CodexTool
411

src/cleanlab_codex/codex.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Optional
3+
from typing import Optional
4+
5+
from beartype.typing import List, Tuple
46

57
from cleanlab_codex.internal.project import create_project, query_project
68
from cleanlab_codex.internal.utils import init_codex_client
7-
8-
if TYPE_CHECKING:
9-
from cleanlab_codex.types.entry import Entry, EntryCreate
10-
from cleanlab_codex.types.organization import Organization
9+
from cleanlab_codex.types.entry import Entry, EntryCreate
10+
from cleanlab_codex.types.organization import Organization
1111

1212

1313
class Codex:
1414
"""
1515
A client to interact with Cleanlab Codex.
1616
"""
1717

18-
def __init__(self, key: str | None = None):
18+
def __init__(self, key: Optional[str] = None):
1919
"""Initialize the Codex client.
2020
2121
Args:
@@ -30,11 +30,11 @@ def __init__(self, key: str | None = None):
3030
self.key = key
3131
self._client = init_codex_client(key)
3232

33-
def list_organizations(self) -> list[Organization]:
33+
def list_organizations(self) -> List[Organization]:
3434
"""List the organizations the authenticated user is a member of.
3535
3636
Returns:
37-
list[Organization]: A list of organizations the authenticated user is a member of.
37+
List[Organization]: A list of organizations the authenticated user is a member of.
3838
3939
Raises:
4040
AuthenticationError: If the client is not authenticated with a user-level API Key.
@@ -59,11 +59,11 @@ def create_project(self, name: str, organization_id: str, description: Optional[
5959
description=description,
6060
)
6161

62-
def add_entries(self, entries: list[EntryCreate], project_id: str) -> None:
62+
def add_entries(self, entries: List[EntryCreate], project_id: str) -> None:
6363
"""Add a list of entries to the Codex project.
6464
6565
Args:
66-
entries (list[EntryCreate]): The entries to add to the Codex project.
66+
entries (List[EntryCreate]): The entries to add to the Codex project.
6767
project_id (int): The ID of the project to add the entries to.
6868
6969
Raises:
@@ -102,20 +102,20 @@ def query(
102102
project_id: Optional[str] = None, # TODO: update to uuid once project IDs are changed to UUIDs
103103
fallback_answer: Optional[str] = None,
104104
read_only: bool = False,
105-
) -> tuple[Optional[str], Optional[Entry]]:
105+
) -> Tuple[Optional[str], Optional[Entry]]:
106106
"""Query Codex to check if the Codex project contains an answer to this question and add the question to the Codex project for SME review if it does not.
107107
108108
Args:
109109
question (str): The question to ask the Codex API.
110-
project_id (:obj:`int`, optional): The ID of the project to query.
110+
project_id (:obj:`str`, optional): The ID of the project to query.
111111
If the client is authenticated with a user-level API Key, this is required.
112112
If the client is authenticated with a project-level Access Key, this is optional. The client will use the Access Key's project ID by default.
113113
fallback_answer (:obj:`str`, optional): Optional fallback answer to return if Codex is unable to answer the question.
114114
read_only (:obj:`bool`, optional): Whether to query the Codex API in read-only mode. If True, the question will not be added to the Codex project for SME review.
115115
This can be useful for testing purposes before when setting up your project configuration.
116116
117117
Returns:
118-
tuple[Optional[str], Optional[Entry]]: A tuple representing the answer for the query and the existing or new entry in the Codex project.
118+
Tuple[Optional[str], Optional[Entry]]: A tuple representing the answer for the query and the existing or new entry in the Codex project.
119119
If Codex is able to answer the question, the first element will be the answer returned by Codex and the second element will be the existing entry in the Codex project.
120120
If Codex is unable to answer the question, the first element will be `fallback_answer` if provided, otherwise None, and the second element will be a new entry in the Codex project.
121121
"""

src/cleanlab_codex/codex_tool.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Any, ClassVar, Optional
44

5+
from beartype.typing import Dict, List
6+
57
from cleanlab_codex.codex import Codex
68

79

@@ -10,13 +12,13 @@ class CodexTool:
1012

1113
_tool_name = "ask_advisor"
1214
_tool_description = "Asks an all-knowing advisor this query in cases where it cannot be answered from the provided Context. If the answer is avalible, this returns None."
13-
_tool_properties: ClassVar[dict[str, Any]] = {
15+
_tool_properties: ClassVar[Dict[str, Any]] = {
1416
"question": {
1517
"type": "string",
1618
"description": "The question to ask the advisor. This should be the same as the original user question, except in cases where the user question is missing information that could be additionally clarified.",
1719
}
1820
}
19-
_tool_requirements: ClassVar[list[str]] = ["question"]
21+
_tool_requirements: ClassVar[List[str]] = ["question"]
2022
DEFAULT_FALLBACK_ANSWER = "Based on the available information, I cannot provide a complete answer to this question."
2123

2224
def __init__(
@@ -94,7 +96,7 @@ def query(self, question: str) -> Optional[str]:
9496
"""
9597
return self._codex_client.query(question, project_id=self._project_id, fallback_answer=self._fallback_answer)[0]
9698

97-
def to_openai_tool(self) -> dict[str, Any]:
99+
def to_openai_tool(self) -> Dict[str, Any]:
98100
"""Converts the tool to an OpenAI tool."""
99101
from cleanlab_codex.utils import format_as_openai_tool
100102

src/cleanlab_codex/internal/project.py

+4-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Optional
4-
5-
if TYPE_CHECKING:
6-
from codex import Codex as _Codex
7-
8-
from cleanlab_codex.types.entry import Entry
3+
from beartype.typing import Optional, Tuple
4+
from codex import Codex as _Codex
95

6+
from cleanlab_codex.types.entry import Entry
107
from cleanlab_codex.types.project import ProjectConfig
118

129

@@ -34,7 +31,7 @@ def query_project(
3431
project_id: Optional[str] = None,
3532
fallback_answer: Optional[str] = None,
3633
read_only: bool = False,
37-
) -> tuple[Optional[str], Optional[Entry]]:
34+
) -> Tuple[Optional[str], Optional[Entry]]:
3835
if client.access_key is not None:
3936
project_id = client.projects.access_keys.retrieve_project_id().project_id
4037
elif project_id is None:

src/cleanlab_codex/internal/utils.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import re
5+
from typing import Optional
56

67
from codex import Codex as _Codex
78

@@ -19,7 +20,7 @@ def is_access_key(key: str) -> bool:
1920
return re.match(ACCESS_KEY_PATTERN, key) is not None
2021

2122

22-
def init_codex_client(key: str | None = None) -> _Codex:
23+
def init_codex_client(key: Optional[str] = None) -> _Codex:
2324
if key is None:
2425
if api_key := os.getenv("CODEX_API_KEY"):
2526
return _client_from_api_key(api_key)

src/cleanlab_codex/utils/llamaindex.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

33
from inspect import signature
4-
from typing import Any, Callable
4+
from typing import Any
55

6+
from beartype.typing import Callable, Dict, Type
67
from llama_index.core.bridge.pydantic import BaseModel, FieldInfo, create_model
78

89

9-
def get_function_schema(name: str, func: Callable[..., Any], tool_properties: dict[str, Any]) -> type[BaseModel]:
10+
def get_function_schema(name: str, func: Callable[..., Any], tool_properties: Dict[str, Any]) -> Type[BaseModel]:
1011
fields = {}
1112
params = signature(func).parameters
1213
for param_name in params:

src/cleanlab_codex/utils/openai.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

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

5+
from beartype.typing import Dict, List
56
from pydantic import BaseModel
67

78

src/cleanlab_codex/utils/smolagents.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
from typing import Callable, Dict, Optional
1+
from __future__ import annotations
22

3+
from typing import Optional
4+
5+
from beartype.typing import Callable, Dict
36
from smolagents import Tool # type: ignore
47

58

tests/fixtures/client.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
from typing import Generator
1+
from __future__ import annotations
2+
23
from unittest.mock import MagicMock, patch
34

45
import pytest
6+
from beartype.typing import Generator
7+
from codex import Codex as _Codex
58

69

710
@pytest.fixture
811
def mock_client() -> Generator[MagicMock, None, None]:
912
with patch("cleanlab_codex.codex.init_codex_client") as mock_init:
1013
mock_client = MagicMock()
14+
mock_client.__class__ = _Codex # type: ignore
1115
mock_init.return_value = mock_client
1216
yield mock_client

0 commit comments

Comments
 (0)