from __future__ import annotations import inspect import re import pytest from langgraph_sdk.client import ( AssistantsClient, CronClient, RunsClient, StoreClient, SyncAssistantsClient, SyncCronClient, SyncRunsClient, SyncStoreClient, SyncThreadsClient, ThreadsClient, ) def _public_methods(cls) -> dict[str, object]: methods: dict[str, object] = {} # Use the raw class dict to avoid runtime wrappers from plugins/decorators for name, member in cls.__dict__.items(): if name.startswith("_"): continue if inspect.isfunction(member): methods[name] = member return methods def _strip_self(sig: inspect.Signature) -> inspect.Signature: params = list(sig.parameters.values()) if params and params[0].name == "self": params = params[1:] return sig.replace(parameters=params) def _normalize_return_annotation(ann: object) -> str: s = str(ann) s = re.sub(r"\s+", "", s) s = s.replace("typing.", "").replace("collections.abc.", "") s = re.sub(r"AsyncGenerator\[([^,\]]+)(?:,[^\]]*)?\]", r"Iterator[\1]", s) s = re.sub(r"Generator\[([^,\]]+)(?:,[^\]]*)?\]", r"Iterator[\1]", s) s = re.sub(r"AsyncIterator\[(.+)\]", r"Iterator[\1]", s) s = re.sub(r"AsyncIterable\[(.+)\]", r"Iterable[\1]", s) # Normalize Async/Sync class prefixes so AsyncFoo and SyncFoo both compare as Foo. s = re.sub(r"\bAsync([A-Z])", r"\1", s) s = re.sub(r"\bSync([A-Z])", r"\1", s) return s ASYNC_ONLY_METHODS: dict[str, set[str]] = {} @pytest.mark.parametrize( "async_cls,sync_cls", [ (AssistantsClient, SyncAssistantsClient), (ThreadsClient, SyncThreadsClient), (RunsClient, SyncRunsClient), (CronClient, SyncCronClient), (StoreClient, SyncStoreClient), ], ) def test_sync_api_matches_async(async_cls, sync_cls): async_methods = _public_methods(async_cls) sync_methods = _public_methods(sync_cls) allowlist = ASYNC_ONLY_METHODS.get(async_cls.__name__, set()) async_method_names = set(async_methods.keys()) - allowlist # Method name parity (modulo the async-only allowlist). assert sync_methods.keys() == async_method_names, ( f"Method sets differ: async-only={async_method_names - set(sync_methods)}, sync-only={set(sync_methods) - async_method_names}" ) for name in async_method_names: async_fn = async_methods[name] sync_fn = sync_methods[name] # Use inspect.signature for parameter names (robust across versions) async_sig = _strip_self(inspect.signature(async_fn)) # type: ignore sync_sig = _strip_self(inspect.signature(sync_fn)) # type: ignore a_names = list(async_sig.parameters.keys()) s_names = list(sync_sig.parameters.keys()) assert set(a_names) == set(s_names), ( f"Parameter names differ for {async_cls.__name__}.{name}: " f"async={a_names}, sync={s_names}" ) # Compare default presence and parameter kinds (with some tolerance) a_params = async_sig.parameters s_params = sync_sig.parameters def kinds_compatible( akind: inspect._ParameterKind, skind: inspect._ParameterKind ) -> bool: if akind == skind: return True return { inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, } == {akind, skind} for pname in set(a_names) & set(s_names): apar = a_params[pname] spar = s_params[pname] assert kinds_compatible(apar.kind, spar.kind), ( f"Parameter kind mismatch for {async_cls.__name__}.{name}.{pname}: " f"async={apar.kind}, sync={spar.kind}" ) assert (apar.default is inspect._empty) == ( spar.default is inspect._empty ), ( f"Default presence mismatch for {async_cls.__name__}.{name}.{pname}: " f"async_has_default={apar.default is not inspect._empty}, " f"sync_has_default={spar.default is not inspect._empty}" ) # Return annotations must match or be iterator-equivalent a_ret = _normalize_return_annotation(async_sig.return_annotation) s_ret = _normalize_return_annotation(sync_sig.return_annotation) assert a_ret == s_ret, ( f"Return annotation mismatch for {async_cls.__name__}.{name}: " f"async={a_ret}, sync={s_ret}" )