Skip to content

Commit bb25387

Browse files
committed
Improve import startup with lazy top-level exports (refs openai#2819)
Defer azure, bedrock, streaming, pydantic_function_tool and websocket reconnection exports behind a module __getattr__ so that `import openai` no longer eagerly imports boto3 (bedrock) or the streaming stack. OPENAI_EAGER_IMPORT=1 resolves them up-front to surface import errors.
1 parent d64d811 commit bb25387

4 files changed

Lines changed: 348 additions & 25 deletions

File tree

scripts/bench_import.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import os
5+
import sys
6+
import time
7+
import argparse
8+
import subprocess
9+
from pathlib import Path
10+
11+
12+
def _pythonpath_for_repo() -> str | None:
13+
src = Path(__file__).resolve().parents[1] / "src"
14+
if not src.exists():
15+
return None
16+
17+
existing = os.environ.get("PYTHONPATH")
18+
if existing:
19+
return f"{src}{os.pathsep}{existing}"
20+
return str(src)
21+
22+
23+
def _cold_import_seconds(repeats: int, env: dict[str, str]) -> list[float]:
24+
samples: list[float] = []
25+
for _ in range(repeats):
26+
start = time.perf_counter()
27+
subprocess.run([sys.executable, "-c", "import openai"], check=True, env=env, stdout=subprocess.DEVNULL)
28+
samples.append(time.perf_counter() - start)
29+
return samples
30+
31+
32+
def _importtime_output(env: dict[str, str]) -> str:
33+
proc = subprocess.run(
34+
[sys.executable, "-X", "importtime", "-c", "import openai"],
35+
check=True,
36+
env=env,
37+
stderr=subprocess.PIPE,
38+
stdout=subprocess.DEVNULL,
39+
text=True,
40+
)
41+
return proc.stderr
42+
43+
44+
def _parse_importtime(importtime_stderr: str) -> list[tuple[int, str]]:
45+
rows: list[tuple[int, str]] = []
46+
for line in importtime_stderr.splitlines():
47+
if "| " not in line:
48+
continue
49+
if "import time:" not in line:
50+
continue
51+
_, _, payload = line.partition("import time:")
52+
parts = [p.strip() for p in payload.split("|")]
53+
if len(parts) != 3:
54+
continue
55+
cumulative_raw = parts[1]
56+
module = parts[2]
57+
if not module.startswith("openai"):
58+
continue
59+
try:
60+
cumulative = int(cumulative_raw)
61+
except ValueError:
62+
continue
63+
rows.append((cumulative, module))
64+
rows.sort(reverse=True)
65+
return rows
66+
67+
68+
def main() -> int:
69+
parser = argparse.ArgumentParser(description="Benchmark openai import time for this checkout.")
70+
parser.add_argument("--repeats", type=int, default=5, help="Number of cold imports to sample")
71+
parser.add_argument("--top", type=int, default=20, help="How many importtime rows to print")
72+
args = parser.parse_args()
73+
74+
env = dict(os.environ)
75+
pythonpath = _pythonpath_for_repo()
76+
if pythonpath is not None:
77+
env["PYTHONPATH"] = pythonpath
78+
79+
samples = _cold_import_seconds(repeats=args.repeats, env=env)
80+
avg = sum(samples) / len(samples)
81+
82+
print(f"Python: {sys.executable}")
83+
print(f"Samples (s): {[round(s, 4) for s in samples]}")
84+
print(f"Average cold import (s): {avg:.4f}")
85+
print()
86+
87+
rows = _parse_importtime(_importtime_output(env))
88+
print(f"Top {min(args.top, len(rows))} cumulative importtime rows (us):")
89+
for cumulative, module in rows[: args.top]:
90+
print(f"{cumulative:>8} {module}")
91+
92+
return 0
93+
94+
95+
if __name__ == "__main__":
96+
raise SystemExit(main())

src/openai/__init__.py

Lines changed: 153 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import typing as _t
77
from typing_extensions import override
88

9-
from . import types
109
from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given
1110
from ._utils import file_from_path
1211
from ._client import Client, OpenAI, Stream, Timeout, Transport, AsyncClient, AsyncOpenAI, AsyncStream, RequestOptions
@@ -39,7 +38,6 @@
3938
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
4039
from ._utils._logs import setup_logging as _setup_logging
4140
from ._legacy_response import HttpxBinaryResponseContent as HttpxBinaryResponseContent
42-
from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides
4341

4442
__all__ = [
4543
"types",
@@ -98,15 +96,15 @@
9896
if not _t.TYPE_CHECKING:
9997
from ._utils._resources_proxy import resources as resources
10098

101-
from .lib import azure as _azure, bedrock as _bedrock, pydantic_function_tool as pydantic_function_tool
99+
from . import types as types
100+
101+
if _t.TYPE_CHECKING:
102+
from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI, AzureADTokenProvider
103+
from .lib.bedrock import BedrockOpenAI, AsyncBedrockOpenAI, BedrockTokenProvider
104+
from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides
105+
102106
from .version import VERSION as VERSION
103-
from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI
104-
from .lib.bedrock import BedrockOpenAI as BedrockOpenAI, AsyncBedrockOpenAI as AsyncBedrockOpenAI
105107
from .lib._old_api import *
106-
from .lib.streaming import (
107-
AssistantEventHandler as AssistantEventHandler,
108-
AsyncAssistantEventHandler as AsyncAssistantEventHandler,
109-
)
110108

111109
_setup_logging()
112110

@@ -117,12 +115,108 @@
117115
__locals = locals()
118116
for __name in __all__:
119117
if not __name.startswith("__"):
118+
__obj = __locals.get(__name)
119+
if __obj is None:
120+
continue
120121
try:
121-
__locals[__name].__module__ = "openai"
122+
__obj.__module__ = "openai"
122123
except (TypeError, AttributeError):
123124
# Some of our exported symbols are builtins which we can't set attributes for.
124125
pass
125126

127+
128+
def _is_truthy_env_var(name: str) -> bool:
129+
value = _os.environ.get(name, "")
130+
return value not in ("", "0", "false", "False")
131+
132+
133+
def _lazy_azure_openai() -> object:
134+
from .lib.azure import AzureOpenAI
135+
136+
return AzureOpenAI
137+
138+
139+
def _lazy_async_azure_openai() -> object:
140+
from .lib.azure import AsyncAzureOpenAI
141+
142+
return AsyncAzureOpenAI
143+
144+
145+
def _lazy_bedrock_openai() -> object:
146+
from .lib.bedrock import BedrockOpenAI
147+
148+
return BedrockOpenAI
149+
150+
151+
def _lazy_async_bedrock_openai() -> object:
152+
from .lib.bedrock import AsyncBedrockOpenAI
153+
154+
return AsyncBedrockOpenAI
155+
156+
157+
def _lazy_reconnecting_event() -> object:
158+
from .types.websocket_reconnection import ReconnectingEvent
159+
160+
return ReconnectingEvent
161+
162+
163+
def _lazy_reconnecting_overrides() -> object:
164+
from .types.websocket_reconnection import ReconnectingOverrides
165+
166+
return ReconnectingOverrides
167+
168+
169+
def _lazy_pydantic_function_tool() -> object:
170+
from .lib._tools import pydantic_function_tool
171+
172+
return pydantic_function_tool
173+
174+
175+
def _lazy_assistant_event_handler() -> object:
176+
from .lib.streaming import AssistantEventHandler
177+
178+
return AssistantEventHandler
179+
180+
181+
def _lazy_async_assistant_event_handler() -> object:
182+
from .lib.streaming import AsyncAssistantEventHandler
183+
184+
return AsyncAssistantEventHandler
185+
186+
187+
_LAZY_EXPORTS: dict[str, _t.Callable[[], object]] = {
188+
"AzureOpenAI": _lazy_azure_openai,
189+
"AsyncAzureOpenAI": _lazy_async_azure_openai,
190+
"BedrockOpenAI": _lazy_bedrock_openai,
191+
"AsyncBedrockOpenAI": _lazy_async_bedrock_openai,
192+
"ReconnectingEvent": _lazy_reconnecting_event,
193+
"ReconnectingOverrides": _lazy_reconnecting_overrides,
194+
"pydantic_function_tool": _lazy_pydantic_function_tool,
195+
"AssistantEventHandler": _lazy_assistant_event_handler,
196+
"AsyncAssistantEventHandler": _lazy_async_assistant_event_handler,
197+
}
198+
199+
200+
def __getattr__(name: str) -> object:
201+
if name in _LAZY_EXPORTS:
202+
value = _LAZY_EXPORTS[name]()
203+
globals()[name] = value
204+
return value
205+
206+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
207+
208+
209+
def _resolve_eager_imports() -> None:
210+
if not _is_truthy_env_var("OPENAI_EAGER_IMPORT"):
211+
return
212+
213+
# Resolve all lazy exports up-front in eager mode to catch import failures in CI/dev.
214+
for name in _LAZY_EXPORTS:
215+
__getattr__(name)
216+
217+
218+
_resolve_eager_imports()
219+
126220
# ------ Module level client ------
127221
import typing as _t
128222
import typing_extensions as _te
@@ -163,11 +257,11 @@
163257

164258
azure_ad_token: str | None = _os.environ.get("AZURE_OPENAI_AD_TOKEN")
165259

166-
azure_ad_token_provider: _azure.AzureADTokenProvider | None = None
260+
azure_ad_token_provider: AzureADTokenProvider | None = None
167261

168262
_bedrock_api_key: str | None = None
169263

170-
bedrock_token_provider: _bedrock.BedrockTokenProvider | None = None
264+
bedrock_token_provider: BedrockTokenProvider | None = None
171265

172266

173267
class _ModuleClient(OpenAI):
@@ -297,21 +391,55 @@ def _client(self, value: _httpx.Client) -> None: # type: ignore
297391
http_client = value
298392

299393

300-
class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore
301-
...
394+
def _create_azure_module_client_class() -> type[OpenAI]:
395+
from .lib.azure import AzureOpenAI
302396

397+
class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore
398+
...
303399

304-
class _BedrockModuleClient(_ModuleClient, BedrockOpenAI): # type: ignore
305-
@property # type: ignore
306-
@override
307-
def api_key(self) -> str | None:
308-
return api_key if api_key is not None else _bedrock_api_key
400+
return _AzureModuleClient
309401

310-
@api_key.setter # type: ignore
311-
def api_key(self, value: str | None) -> None: # type: ignore
312-
global _bedrock_api_key
313402

314-
_bedrock_api_key = value
403+
_AZURE_MODULE_CLIENT_CLASS: type[OpenAI] | None = None
404+
405+
406+
def _azure_module_client_class() -> type[OpenAI]:
407+
global _AZURE_MODULE_CLIENT_CLASS
408+
409+
if _AZURE_MODULE_CLIENT_CLASS is None:
410+
_AZURE_MODULE_CLIENT_CLASS = _create_azure_module_client_class()
411+
412+
return _AZURE_MODULE_CLIENT_CLASS
413+
414+
415+
def _create_bedrock_module_client_class() -> type[OpenAI]:
416+
from .lib.bedrock import BedrockOpenAI
417+
418+
class _BedrockModuleClient(_ModuleClient, BedrockOpenAI): # type: ignore
419+
@property # type: ignore
420+
@override
421+
def api_key(self) -> str | None:
422+
return api_key if api_key is not None else _bedrock_api_key
423+
424+
@api_key.setter # type: ignore
425+
def api_key(self, value: str | None) -> None: # type: ignore
426+
global _bedrock_api_key
427+
428+
_bedrock_api_key = value
429+
430+
return _BedrockModuleClient
431+
432+
433+
_BEDROCK_MODULE_CLIENT_CLASS: type[OpenAI] | None = None
434+
435+
436+
def _bedrock_module_client_class() -> type[OpenAI]:
437+
global _BEDROCK_MODULE_CLIENT_CLASS
438+
439+
if _BEDROCK_MODULE_CLIENT_CLASS is None:
440+
_BEDROCK_MODULE_CLIENT_CLASS = _create_bedrock_module_client_class()
441+
442+
return _BEDROCK_MODULE_CLIENT_CLASS
315443

316444

317445
class _AmbiguousModuleClientUsageError(OpenAIError):
@@ -374,7 +502,7 @@ def _load_client() -> OpenAI: # type: ignore[reportUnusedFunction]
374502
api_type = "openai"
375503

376504
if api_type == "azure":
377-
_client = _AzureModuleClient( # type: ignore
505+
_client = _azure_module_client_class()( # type: ignore
378506
api_version=api_version,
379507
azure_endpoint=azure_endpoint,
380508
api_key=api_key,
@@ -391,7 +519,7 @@ def _load_client() -> OpenAI: # type: ignore[reportUnusedFunction]
391519
return _client
392520

393521
if api_type == "amazon-bedrock":
394-
_client = _BedrockModuleClient( # type: ignore
522+
_client = _bedrock_module_client_class()( # type: ignore
395523
api_key=api_key,
396524
bedrock_token_provider=bedrock_token_provider,
397525
organization=organization,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import importlib
6+
7+
import pytest
8+
9+
10+
def _openai_modules() -> dict[str, object]:
11+
return {name: mod for name, mod in sys.modules.items() if name == "openai" or name.startswith("openai.")}
12+
13+
14+
def _restore_openai_modules(original_modules: dict[str, object]) -> None:
15+
for name in list(sys.modules):
16+
if name == "openai" or name.startswith("openai."):
17+
sys.modules.pop(name, None)
18+
sys.modules.update(original_modules)
19+
20+
21+
@pytest.mark.skipif(os.environ.get("OPENAI_LIVE") != "1", reason="requires OPENAI_LIVE=1")
22+
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="requires OPENAI_API_KEY")
23+
def test_eager_import_with_live_token_allows_real_request(monkeypatch) -> None:
24+
# Exercise eager mode in a real SDK flow behind explicit live-test flags.
25+
monkeypatch.setenv("OPENAI_EAGER_IMPORT", "1")
26+
original_modules = _openai_modules()
27+
28+
for name in original_modules:
29+
sys.modules.pop(name, None)
30+
31+
client = None
32+
try:
33+
openai = importlib.import_module("openai")
34+
35+
assert "openai.types" in sys.modules
36+
assert "openai.lib.azure" in sys.modules
37+
assert "AzureOpenAI" in openai.__dict__
38+
39+
client = openai.OpenAI(timeout=20.0)
40+
page = client.models.list()
41+
assert page.data is not None
42+
finally:
43+
if client is not None:
44+
client.close()
45+
_restore_openai_modules(original_modules)

0 commit comments

Comments
 (0)