diff --git a/scripts/bench_import.py b/scripts/bench_import.py new file mode 100644 index 0000000000..3d63cfb7f5 --- /dev/null +++ b/scripts/bench_import.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import sys +import time +import argparse +import subprocess +from pathlib import Path + + +def _pythonpath_for_repo() -> str | None: + src = Path(__file__).resolve().parents[1] / "src" + if not src.exists(): + return None + + existing = os.environ.get("PYTHONPATH") + if existing: + return f"{src}{os.pathsep}{existing}" + return str(src) + + +def _cold_import_seconds(repeats: int, env: dict[str, str]) -> list[float]: + samples: list[float] = [] + for _ in range(repeats): + start = time.perf_counter() + subprocess.run([sys.executable, "-c", "import openai"], check=True, env=env, stdout=subprocess.DEVNULL) + samples.append(time.perf_counter() - start) + return samples + + +def _importtime_output(env: dict[str, str]) -> str: + proc = subprocess.run( + [sys.executable, "-X", "importtime", "-c", "import openai"], + check=True, + env=env, + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + text=True, + ) + return proc.stderr + + +def _parse_importtime(importtime_stderr: str) -> list[tuple[int, str]]: + rows: list[tuple[int, str]] = [] + for line in importtime_stderr.splitlines(): + if "| " not in line: + continue + if "import time:" not in line: + continue + _, _, payload = line.partition("import time:") + parts = [p.strip() for p in payload.split("|")] + if len(parts) != 3: + continue + cumulative_raw = parts[1] + module = parts[2] + if not module.startswith("openai"): + continue + try: + cumulative = int(cumulative_raw) + except ValueError: + continue + rows.append((cumulative, module)) + rows.sort(reverse=True) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark openai import time for this checkout.") + parser.add_argument("--repeats", type=int, default=5, help="Number of cold imports to sample") + parser.add_argument("--top", type=int, default=20, help="How many importtime rows to print") + args = parser.parse_args() + + env = dict(os.environ) + pythonpath = _pythonpath_for_repo() + if pythonpath is not None: + env["PYTHONPATH"] = pythonpath + + samples = _cold_import_seconds(repeats=args.repeats, env=env) + avg = sum(samples) / len(samples) + + print(f"Python: {sys.executable}") + print(f"Samples (s): {[round(s, 4) for s in samples]}") + print(f"Average cold import (s): {avg:.4f}") + print() + + rows = _parse_importtime(_importtime_output(env)) + print(f"Top {min(args.top, len(rows))} cumulative importtime rows (us):") + for cumulative, module in rows[: args.top]: + print(f"{cumulative:>8} {module}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/openai/__init__.py b/src/openai/__init__.py index fc9675a8b5..21558d4340 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -6,7 +6,6 @@ import typing as _t from typing_extensions import override -from . import types from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import Client, OpenAI, Stream, Timeout, Transport, AsyncClient, AsyncOpenAI, AsyncStream, RequestOptions @@ -96,14 +95,21 @@ if not _t.TYPE_CHECKING: from ._utils._resources_proxy import resources as resources -from .lib import azure as _azure, pydantic_function_tool as pydantic_function_tool +if _t.TYPE_CHECKING: + from . import types as types + from .lib.azure import ( + AzureOpenAI as AzureOpenAI, + AsyncAzureOpenAI as AsyncAzureOpenAI, + AzureADTokenProvider, + ) + from .lib._tools import pydantic_function_tool as pydantic_function_tool + from .lib.streaming import ( + AssistantEventHandler as AssistantEventHandler, + AsyncAssistantEventHandler as AsyncAssistantEventHandler, + ) + from .version import VERSION as VERSION -from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI from .lib._old_api import * -from .lib.streaming import ( - AssistantEventHandler as AssistantEventHandler, - AsyncAssistantEventHandler as AsyncAssistantEventHandler, -) _setup_logging() @@ -114,12 +120,70 @@ __locals = locals() for __name in __all__: if not __name.startswith("__"): + __obj = __locals.get(__name) + if __obj is None: + continue try: - __locals[__name].__module__ = "openai" + __obj.__module__ = "openai" except (TypeError, AttributeError): # Some of our exported symbols are builtins which we can't set attributes for. pass + +def _lazy_azure_openai() -> object: + from .lib.azure import AzureOpenAI + + return AzureOpenAI + + +def _lazy_types_module() -> object: + import importlib + + return importlib.import_module("openai.types") + + +def _lazy_async_azure_openai() -> object: + from .lib.azure import AsyncAzureOpenAI + + return AsyncAzureOpenAI + + +def _lazy_pydantic_function_tool() -> object: + from .lib._tools import pydantic_function_tool + + return pydantic_function_tool + + +def _lazy_assistant_event_handler() -> object: + from .lib.streaming import AssistantEventHandler + + return AssistantEventHandler + + +def _lazy_async_assistant_event_handler() -> object: + from .lib.streaming import AsyncAssistantEventHandler + + return AsyncAssistantEventHandler + + +_LAZY_EXPORTS: dict[str, _t.Callable[[], object]] = { + "types": _lazy_types_module, + "AzureOpenAI": _lazy_azure_openai, + "AsyncAzureOpenAI": _lazy_async_azure_openai, + "pydantic_function_tool": _lazy_pydantic_function_tool, + "AssistantEventHandler": _lazy_assistant_event_handler, + "AsyncAssistantEventHandler": _lazy_async_assistant_event_handler, +} + + +def __getattr__(name: str) -> object: + if name in _LAZY_EXPORTS: + value = _LAZY_EXPORTS[name]() + globals()[name] = value + return value + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # ------ Module level client ------ import typing as _t import typing_extensions as _te @@ -158,7 +222,7 @@ azure_ad_token: str | None = _os.environ.get("AZURE_OPENAI_AD_TOKEN") -azure_ad_token_provider: _azure.AzureADTokenProvider | None = None +azure_ad_token_provider: AzureADTokenProvider | None = None class _ModuleClient(OpenAI): @@ -277,8 +341,25 @@ def _client(self, value: _httpx.Client) -> None: # type: ignore http_client = value -class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore - ... +def _create_azure_module_client_class() -> type[AzureOpenAI]: + from .lib.azure import AzureOpenAI + + class _AzureModuleClient(_ModuleClient, AzureOpenAI): # type: ignore + ... + + return _AzureModuleClient # type: ignore[return-value] + + +_azure_module_client_class_cache: type[AzureOpenAI] | None = None + + +def _azure_module_client_class() -> type[AzureOpenAI]: + global _azure_module_client_class_cache + + if _azure_module_client_class_cache is None: + _azure_module_client_class_cache = _create_azure_module_client_class() + + return _azure_module_client_class_cache class _AmbiguousModuleClientUsageError(OpenAIError): @@ -341,7 +422,7 @@ def _load_client() -> OpenAI: # type: ignore[reportUnusedFunction] api_type = "openai" if api_type == "azure": - _client = _AzureModuleClient( # type: ignore + _client = _azure_module_client_class()( # type: ignore api_version=api_version, azure_endpoint=azure_endpoint, api_key=api_key, diff --git a/src/openai/lib/__init__.py b/src/openai/lib/__init__.py index 5c6cb782c0..a43fdc5098 100644 --- a/src/openai/lib/__init__.py +++ b/src/openai/lib/__init__.py @@ -1,2 +1,23 @@ -from ._tools import pydantic_function_tool as pydantic_function_tool -from ._parsing import ResponseFormatT as ResponseFormatT +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ._tools import pydantic_function_tool as pydantic_function_tool + from ._parsing import ResponseFormatT as ResponseFormatT + + +def __getattr__(name: str) -> Any: + if name == "pydantic_function_tool": + from ._tools import pydantic_function_tool + + globals()[name] = pydantic_function_tool + return pydantic_function_tool + + if name == "ResponseFormatT": + from ._parsing import ResponseFormatT + + globals()[name] = ResponseFormatT + return ResponseFormatT + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/lib/test_import_surface_live.py b/tests/lib/test_import_surface_live.py new file mode 100644 index 0000000000..7658cdf34b --- /dev/null +++ b/tests/lib/test_import_surface_live.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os +import sys +import importlib +from types import ModuleType + +import pytest + + +def _openai_modules() -> dict[str, ModuleType]: + return {name: mod for name, mod in sys.modules.items() if name == "openai" or name.startswith("openai.")} + + +def _restore_openai_modules(original_modules: dict[str, ModuleType]) -> None: + for name in list(sys.modules): + if name == "openai" or name.startswith("openai."): + sys.modules.pop(name, None) + sys.modules.update(original_modules) + + +_LAZY_EXPORT_NAMES = ( + "AzureOpenAI", + "AsyncAzureOpenAI", + "pydantic_function_tool", + "AssistantEventHandler", + "AsyncAssistantEventHandler", +) + + +def _resolve_lazy_exports(openai_module: object) -> None: + for name in _LAZY_EXPORT_NAMES: + getattr(openai_module, name) + + +@pytest.mark.skipif(os.environ.get("OPENAI_LIVE") != "1", reason="requires OPENAI_LIVE=1") +@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="requires OPENAI_API_KEY") +def test_explicit_lazy_export_resolution_allows_real_request() -> None: + original_modules = _openai_modules() + + for name in original_modules: + sys.modules.pop(name, None) + + client = None + try: + openai = importlib.import_module("openai") + + _resolve_lazy_exports(openai) + + assert "openai.lib.azure" in sys.modules + assert "AzureOpenAI" in openai.__dict__ + + client = openai.OpenAI(timeout=20.0) + page = client.models.list() + assert page.data is not None + finally: + if client is not None: + client.close() + _restore_openai_modules(original_modules) diff --git a/tests/test_import_surface.py b/tests/test_import_surface.py new file mode 100644 index 0000000000..f520031b91 --- /dev/null +++ b/tests/test_import_surface.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import importlib +from types import ModuleType + + +def _openai_modules() -> dict[str, ModuleType]: + return {name: mod for name, mod in sys.modules.items() if name == "openai" or name.startswith("openai.")} + + +def _restore_openai_modules(original_modules: dict[str, ModuleType]) -> None: + for name in list(sys.modules): + if name == "openai" or name.startswith("openai."): + sys.modules.pop(name, None) + sys.modules.update(original_modules) + + +_LAZY_EXPORT_NAMES = ( + "AzureOpenAI", + "AsyncAzureOpenAI", + "pydantic_function_tool", + "AssistantEventHandler", + "AsyncAssistantEventHandler", +) + +_LAZY_EXPORT_MODULES = ( + "openai.lib.azure", + "openai.lib.streaming", + "openai.lib._tools", +) + + +def _resolve_lazy_exports(openai_module: object) -> None: + for name in _LAZY_EXPORT_NAMES: + getattr(openai_module, name) + + +def test_openai_azure_is_lazy_imported() -> None: + original_modules = _openai_modules() + + for name in original_modules: + sys.modules.pop(name, None) + + try: + openai = importlib.import_module("openai") + + assert "openai.lib.azure" not in sys.modules + + assert openai.AzureOpenAI is not None + assert "openai.lib.azure" in sys.modules + finally: + _restore_openai_modules(original_modules) + + +def test_openai_can_explicitly_resolve_lazy_exports() -> None: + original_modules = _openai_modules() + + for name in original_modules: + sys.modules.pop(name, None) + + try: + openai = importlib.import_module("openai") + + for mod_name in _LAZY_EXPORT_MODULES: + assert mod_name not in sys.modules + + _resolve_lazy_exports(openai) + + for mod_name in _LAZY_EXPORT_MODULES: + assert mod_name in sys.modules + for attr_name in _LAZY_EXPORT_NAMES: + assert attr_name in openai.__dict__ + finally: + _restore_openai_modules(original_modules)