Summary
braintrust.Experiment resolves to the wrong type under mypy. Both a TypedDict (the REST API response shape, from generated_types) and a class (the ObjectFetcher subclass, from logger) are exported under the same name. At runtime the class wins, but mypy picks the TypedDict — making .fetch(), .log(), etc. invisible to static analysis.
Reproduction
# repro.py
import braintrust
def f(exp: braintrust.Experiment) -> None:
for row in exp.fetch(): # error: "Experiment" has no attribute "fetch"
print(row["id"])
$ mypy repro.py
repro.py:4: error: "Experiment" has no attribute "fetch" [attr-defined]
Importing directly from the submodule works around it:
from braintrust.logger import Experiment # resolves correctly
Root cause
__init__.py does:
from .generated_types import * # line 69 — brings TypedDict `Experiment`
from .logger import * # line 75 — brings class `Experiment`
At runtime the logger class wins (last import). But mypy's wildcard-import resolution is undefined for conflicting names and in practice picks the TypedDict from generated_types.
Affected names
| Name |
generated_types |
logger |
Experiment |
TypedDict (API response shape) |
Class (ObjectFetcher) |
Dataset |
TypedDict (API response shape) |
Class (ObjectFetcher) |
Prompt |
TypedDict (API response shape) |
Class |
Project |
TypedDict (API response shape) |
dataclass |
Possible resolutions
There are a few ways to fix the ambiguity — depends on what's intended to be public:
- Disambiguate the names — rename the generated TypedDicts (e.g.
ExperimentResponse, DatasetResponse) so both can coexist at the top level without collision.
- Remove TypedDicts from the public namespace — exclude the colliding names from
generated_types.__all__ so only the runtime classes export at the top level. TypedDicts remain importable from braintrust._generated_types.
- Ensure the class wins in mypy — add explicit
from .logger import Experiment as Experiment after the wildcards (note: I tested this and it did NOT resolve the issue in mypy 2.1.0; only removing the TypedDicts from the wildcard did).
I verified locally that option 2 resolves the issue — after removing Experiment, Dataset, Prompt, and Project from generated_types.py's __all__, mypy correctly resolves the logger classes. But option 1 may be preferable if users depend on the TypedDicts at the top level.
Environment
- braintrust 0.24.0 (latest)
- mypy 2.1.0
- Python 3.14
Summary
braintrust.Experimentresolves to the wrong type under mypy. Both a TypedDict (the REST API response shape, fromgenerated_types) and a class (theObjectFetchersubclass, fromlogger) are exported under the same name. At runtime the class wins, but mypy picks the TypedDict — making.fetch(),.log(), etc. invisible to static analysis.Reproduction
Importing directly from the submodule works around it:
Root cause
__init__.pydoes:At runtime the logger class wins (last import). But mypy's wildcard-import resolution is undefined for conflicting names and in practice picks the TypedDict from
generated_types.Affected names
generated_typesloggerExperimentDatasetPromptProjectPossible resolutions
There are a few ways to fix the ambiguity — depends on what's intended to be public:
ExperimentResponse,DatasetResponse) so both can coexist at the top level without collision.generated_types.__all__so only the runtime classes export at the top level. TypedDicts remain importable frombraintrust._generated_types.from .logger import Experiment as Experimentafter the wildcards (note: I tested this and it did NOT resolve the issue in mypy 2.1.0; only removing the TypedDicts from the wildcard did).I verified locally that option 2 resolves the issue — after removing
Experiment,Dataset,Prompt, andProjectfromgenerated_types.py's__all__, mypy correctly resolves the logger classes. But option 1 may be preferable if users depend on the TypedDicts at the top level.Environment