Skip to content

Commit 9378a2d

Browse files
committed
Improve import startup with lazy top-level exports (refs openai#2819)
1 parent 49e0775 commit 9378a2d

4 files changed

Lines changed: 303 additions & 12 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 argparse
5+
import os
6+
import subprocess
7+
import sys
8+
import time
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: 108 additions & 12 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
@@ -87,14 +86,23 @@
8786
if not _t.TYPE_CHECKING:
8887
from ._utils._resources_proxy import resources as resources
8988

90-
from .lib import azure as _azure, pydantic_function_tool as pydantic_function_tool
89+
if _t.TYPE_CHECKING:
90+
from . import types as types
91+
from .lib.azure import AzureADTokenProvider, AzureOpenAI, AsyncAzureOpenAI
92+
else:
93+
from ._utils._proxy import LazyProxy as _LazyProxy
94+
95+
class _TypesProxy(_LazyProxy[_t.Any]):
96+
@override
97+
def __load__(self) -> _t.Any:
98+
import importlib
99+
100+
return importlib.import_module("openai.types")
101+
102+
types = _TypesProxy().__as_proxied__()
103+
91104
from .version import VERSION as VERSION
92-
from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI
93105
from .lib._old_api import *
94-
from .lib.streaming import (
95-
AssistantEventHandler as AssistantEventHandler,
96-
AsyncAssistantEventHandler as AsyncAssistantEventHandler,
97-
)
98106

99107
_setup_logging()
100108

@@ -105,12 +113,83 @@
105113
__locals = locals()
106114
for __name in __all__:
107115
if not __name.startswith("__"):
116+
__obj = __locals.get(__name)
117+
if __obj is None:
118+
continue
108119
try:
109-
__locals[__name].__module__ = "openai"
120+
__obj.__module__ = "openai"
110121
except (TypeError, AttributeError):
111122
# Some of our exported symbols are builtins which we can't set attributes for.
112123
pass
113124

125+
126+
def _is_truthy_env_var(name: str) -> bool:
127+
value = _os.environ.get(name, "")
128+
return value not in ("", "0", "false", "False")
129+
130+
131+
def _lazy_azure_openai() -> object:
132+
from .lib.azure import AzureOpenAI
133+
134+
return AzureOpenAI
135+
136+
137+
def _lazy_async_azure_openai() -> object:
138+
from .lib.azure import AsyncAzureOpenAI
139+
140+
return AsyncAzureOpenAI
141+
142+
143+
def _lazy_pydantic_function_tool() -> object:
144+
from .lib._tools import pydantic_function_tool
145+
146+
return pydantic_function_tool
147+
148+
149+
def _lazy_assistant_event_handler() -> object:
150+
from .lib.streaming import AssistantEventHandler
151+
152+
return AssistantEventHandler
153+
154+
155+
def _lazy_async_assistant_event_handler() -> object:
156+
from .lib.streaming import AsyncAssistantEventHandler
157+
158+
return AsyncAssistantEventHandler
159+
160+
161+
_LAZY_EXPORTS: dict[str, _t.Callable[[], object]] = {
162+
"AzureOpenAI": _lazy_azure_openai,
163+
"AsyncAzureOpenAI": _lazy_async_azure_openai,
164+
"pydantic_function_tool": _lazy_pydantic_function_tool,
165+
"AssistantEventHandler": _lazy_assistant_event_handler,
166+
"AsyncAssistantEventHandler": _lazy_async_assistant_event_handler,
167+
}
168+
169+
170+
def __getattr__(name: str) -> object:
171+
if name in _LAZY_EXPORTS:
172+
value = _LAZY_EXPORTS[name]()
173+
globals()[name] = value
174+
return value
175+
176+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
177+
178+
179+
def _resolve_eager_imports() -> None:
180+
if not _is_truthy_env_var("OPENAI_EAGER_IMPORT"):
181+
return
182+
183+
import importlib
184+
185+
# Resolve all lazy exports up-front in eager mode to catch import failures in CI/dev.
186+
globals()["types"] = importlib.import_module("openai.types")
187+
for name in _LAZY_EXPORTS:
188+
__getattr__(name)
189+
190+
191+
_resolve_eager_imports()
192+
114193
# ------ Module level client ------
115194
import typing as _t
116195
import typing_extensions as _te
@@ -149,7 +228,7 @@
149228

150229
azure_ad_token: str | None = _os.environ.get("AZURE_OPENAI_AD_TOKEN")
151230

152-
azure_ad_token_provider: _azure.AzureADTokenProvider | None = None
231+
azure_ad_token_provider: AzureADTokenProvider | None = None
153232

154233

155234
class _ModuleClient(OpenAI):
@@ -268,8 +347,25 @@ def _client(self, value: _httpx.Client) -> None: # type: ignore
268347
http_client = value
269348

270349

271-
class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore
272-
...
350+
def _create_azure_module_client_class() -> type[OpenAI]:
351+
from .lib.azure import AzureOpenAI
352+
353+
class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore
354+
...
355+
356+
return _AzureModuleClient
357+
358+
359+
_AZURE_MODULE_CLIENT_CLASS: type[OpenAI] | None = None
360+
361+
362+
def _azure_module_client_class() -> type[OpenAI]:
363+
global _AZURE_MODULE_CLIENT_CLASS
364+
365+
if _AZURE_MODULE_CLIENT_CLASS is None:
366+
_AZURE_MODULE_CLIENT_CLASS = _create_azure_module_client_class()
367+
368+
return _AZURE_MODULE_CLIENT_CLASS
273369

274370

275371
class _AmbiguousModuleClientUsageError(OpenAIError):
@@ -332,7 +428,7 @@ def _load_client() -> OpenAI: # type: ignore[reportUnusedFunction]
332428
api_type = "openai"
333429

334430
if api_type == "azure":
335-
_client = _AzureModuleClient( # type: ignore
431+
_client = _azure_module_client_class()( # type: ignore
336432
api_version=api_version,
337433
azure_endpoint=azure_endpoint,
338434
api_key=api_key,
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)

tests/test_import_surface.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import sys
5+
6+
7+
def _openai_modules() -> dict[str, object]:
8+
return {name: mod for name, mod in sys.modules.items() if name == "openai" or name.startswith("openai.")}
9+
10+
11+
def _restore_openai_modules(original_modules: dict[str, object]) -> None:
12+
for name in list(sys.modules):
13+
if name == "openai" or name.startswith("openai."):
14+
sys.modules.pop(name, None)
15+
sys.modules.update(original_modules)
16+
17+
18+
def test_openai_azure_is_lazy_imported(monkeypatch) -> None:
19+
monkeypatch.delenv("OPENAI_EAGER_IMPORT", raising=False)
20+
original_modules = _openai_modules()
21+
22+
for name in original_modules:
23+
sys.modules.pop(name, None)
24+
25+
try:
26+
openai = importlib.import_module("openai")
27+
28+
assert "openai.lib.azure" not in sys.modules
29+
30+
assert openai.AzureOpenAI is not None
31+
assert "openai.lib.azure" in sys.modules
32+
finally:
33+
_restore_openai_modules(original_modules)
34+
35+
36+
def test_openai_eager_import_resolves_lazy_exports(monkeypatch) -> None:
37+
original_modules = _openai_modules()
38+
monkeypatch.setenv("OPENAI_EAGER_IMPORT", "1")
39+
40+
for name in original_modules:
41+
sys.modules.pop(name, None)
42+
43+
try:
44+
openai = importlib.import_module("openai")
45+
46+
assert "openai.types" in sys.modules
47+
assert "openai.lib.azure" in sys.modules
48+
assert "AzureOpenAI" in openai.__dict__
49+
assert "AsyncAzureOpenAI" in openai.__dict__
50+
assert "pydantic_function_tool" in openai.__dict__
51+
assert "AssistantEventHandler" in openai.__dict__
52+
assert "AsyncAssistantEventHandler" in openai.__dict__
53+
finally:
54+
_restore_openai_modules(original_modules)

0 commit comments

Comments
 (0)