a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
131 lines
4.5 KiB
Python
131 lines
4.5 KiB
Python
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}"
|
|
)
|