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.pyimportbraintrustdeff(exp: braintrust.Experiment) ->None:
forrowinexp.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:
frombraintrust.loggerimportExperiment# resolves correctly
Root cause
__init__.py does:
from .generated_typesimport*# line 69 — brings TypedDict `Experiment`from .loggerimport*# 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