Skip to content

Commit f7e5704

Browse files
authored
test(sigcheck): check function signature parity across backends (#10008)
Opening this in favor of #9383 -- that PR also included all of the breaking changes to unify function signatures and it was too much at once. This PR adds only the signature checking mechanism, plus the requisite xfails to lay out which inconsistencies are currently in Ibis. ## Motivation We want to ensure that, for a given backend, that the argument names, plus usage of positional, positional-only, keyword, and keyword-only arguments match, so that there is API consistency when moving between backends. I've grabbed a few small parts of some of the utilities in Scott Sanderson's `python-interface` project (https://github.com/ssanderson/python-interface). While the upstream is no longer maintained, the goal of that project aligns quite well with some of the issues we face with maintaining consistent interfaces across backends. Note that while the upstream project focused on _runtime_ enforcement of these signatures matching, here it is only run in the test suite. ## Rough procedure Any method that doesn't match can be skipped entirely (this is useful for things like `do_connect`, which cannot reasonably be assumed to match) or individually (by specifying a `pytest.param` and marking the failing backends). Then we scrape across the common parent classes and add any methods that are NOT currently specified in the pre-existing xfailed ones. It's a bit of a nuisance, but it's done, and ideally the manual listing of the inconsistent methods goes away as we unify things. I've opted for not checking that type annotations match, because that seems... unreasonable. This would satisfy #9125 once all of the xfail markers are removed, e.g., it checks that all keyword and positional arguments are standardized.
1 parent 9a853a2 commit f7e5704

File tree

5 files changed

+481
-0
lines changed

5 files changed

+481
-0
lines changed

Diff for: ibis/backends/conftest.py

+25
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ibis.util import promote_tuple
2727

2828
if TYPE_CHECKING:
29+
from ibis.backends import BaseBackend
2930
from ibis.backends.tests.base import BackendTest
3031

3132

@@ -311,6 +312,14 @@ def pytest_runtest_call(item):
311312
if not backend:
312313
# Check item path to see if test is in backend-specific folder
313314
backend = set(_get_backend_names()).intersection(item.path.parts)
315+
if not backend:
316+
# Check if this is one of the uninstantiated backend class fixture
317+
# used for signature checking
318+
backend = [
319+
backend.name
320+
for key, backend in item.funcargs.items()
321+
if key.endswith("backend_cls")
322+
]
314323

315324
if not backend:
316325
return
@@ -390,6 +399,22 @@ def _filter_none_from_raises(kwargs):
390399
item.add_marker(pytest.mark.xfail(reason=reason, **kwargs))
391400

392401

402+
def _get_backend_cls(backend_str: str):
403+
"""Convert a backend string to the test class for the backend."""
404+
backend_mod = importlib.import_module(f"ibis.backends.{backend_str}")
405+
return backend_mod.Backend
406+
407+
408+
@pytest.fixture(params=_get_backends_to_test(), scope="session")
409+
def backend_cls(request) -> BaseBackend:
410+
"""Return the uninstantiated backend class, unconnected.
411+
412+
This is used for signature checking and nothing should be executed."""
413+
414+
cls = _get_backend_cls(request.param)
415+
return cls
416+
417+
393418
@pytest.fixture(params=_get_backends_to_test(), scope="session")
394419
def backend(request, data_dir, tmp_path_factory, worker_id) -> BackendTest:
395420
"""Return an instance of BackendTest, loaded with data."""

Diff for: ibis/backends/tests/signature/__init__.py

Whitespace-only changes.
+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations # noqa: INP001
2+
3+
from inspect import signature
4+
from typing import Any
5+
6+
import pytest
7+
from pytest import param
8+
9+
from ibis.backends.tests.signature.typecheck import compatible
10+
11+
12+
def a1(posarg: int): ...
13+
14+
15+
def b1(posarg: str): ...
16+
17+
18+
def a2(posarg: int, **kwargs: Any): ...
19+
def b2(posarg: str, **kwargs): ...
20+
21+
22+
def a3(posarg: int, other_kwarg: bool = True, **kwargs: Any): ...
23+
def b3(posarg: str, **kwargs: Any): ...
24+
25+
26+
def a4(posarg: int, other_kwarg=True, **kwargs: Any): ...
27+
def b4(posarg: str, **kwargs: Any): ...
28+
29+
30+
def a5(posarg: int, /): ...
31+
def b5(posarg2: str, /): ...
32+
33+
34+
@pytest.mark.parametrize(
35+
"a, b, check_annotations",
36+
[
37+
param(
38+
lambda posarg, *, kwarg1=None, kwarg2=None: ...,
39+
lambda posarg, *, kwarg2=None, kwarg1=None: ...,
40+
True,
41+
id="swapped kwarg order",
42+
),
43+
param(
44+
lambda posarg, *, kwarg1=None, kwarg2=None, kwarg3=None: ...,
45+
lambda posarg, *, kwarg2=None, kwarg1=None: ...,
46+
True,
47+
id="swapped kwarg order w/extra kwarg first",
48+
),
49+
param(
50+
lambda posarg, *, kwarg2=None, kwarg1=None: ...,
51+
lambda posarg, *, kwarg1=None, kwarg2=None, kwarg3=None: ...,
52+
True,
53+
id="swapped kwarg order w/extra kwarg second",
54+
),
55+
param(
56+
a1,
57+
b1,
58+
False,
59+
id="annotations diff types w/out anno check",
60+
),
61+
param(
62+
a2,
63+
b3,
64+
False,
65+
id="annotations different but parity in annotations",
66+
),
67+
param(
68+
a3,
69+
b3,
70+
False,
71+
id="annotations different but parity in annotations for matching kwargs",
72+
),
73+
param(
74+
a4,
75+
b4,
76+
False,
77+
id="annotations different but parity in annotations for matching kwargs",
78+
),
79+
param(
80+
a2,
81+
b2,
82+
False,
83+
id="annotations different, no anno check, but missing annotation",
84+
),
85+
],
86+
)
87+
def test_sigs_compatible(a, b, check_annotations):
88+
sig_a, sig_b = signature(a), signature(b)
89+
assert compatible(sig_a, sig_b, check_annotations=check_annotations)
90+
91+
92+
@pytest.mark.parametrize(
93+
"a, b, check_annotations",
94+
[
95+
param(
96+
lambda posarg, /, *, kwarg2=None, kwarg1=None: ...,
97+
lambda posarg, *, kwarg1=None, kwarg2=None, kwarg3=None: ...,
98+
True,
99+
id="one positional only",
100+
),
101+
param(
102+
lambda posarg, *, kwarg1=None, kwarg2=None: ...,
103+
lambda posarg, kwarg1=None, kwarg2=None: ...,
104+
True,
105+
id="not kwarg only",
106+
),
107+
param(
108+
a1,
109+
b1,
110+
True,
111+
id="annotations diff types w/anno check",
112+
),
113+
param(
114+
a2,
115+
b3,
116+
True,
117+
id="annotations different but parity in annotations",
118+
),
119+
param(
120+
a5,
121+
b5,
122+
False,
123+
id="names different, but positional only",
124+
),
125+
],
126+
)
127+
def test_sigs_incompatible(a, b, check_annotations):
128+
sig_a, sig_b = signature(a), signature(b)
129+
assert not compatible(sig_a, sig_b, check_annotations=check_annotations)

Diff for: ibis/backends/tests/signature/typecheck.py

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""The following was forked from the python-interface project:
2+
3+
* Copyright (c) 2016-2021, Scott Sanderson
4+
5+
Utilities for typed interfaces.
6+
"""
7+
8+
# ruff: noqa: D205, D415, D400
9+
10+
from __future__ import annotations
11+
12+
from functools import partial
13+
from inspect import Parameter, Signature
14+
from itertools import starmap, takewhile, zip_longest
15+
16+
17+
def valfilter(f, d):
18+
return {k: v for k, v in d.items() if f(v)}
19+
20+
21+
def dzip(left, right):
22+
return {k: (left.get(k), right.get(k)) for k in left.keys() & right.keys()}
23+
24+
25+
def complement(f):
26+
def not_f(*args, **kwargs):
27+
return not f(*args, **kwargs)
28+
29+
return not_f
30+
31+
32+
def compatible(
33+
impl_sig: Signature, iface_sig: Signature, check_annotations: bool = True
34+
) -> bool:
35+
"""Check whether ``impl_sig`` is compatible with ``iface_sig``.
36+
37+
Parameters
38+
----------
39+
impl_sig
40+
The signature of the implementation function.
41+
iface_sig
42+
The signature of the interface function.
43+
check_annotations
44+
Whether to also compare signature annotations (default) vs only parameter names.
45+
46+
In general, an implementation is compatible with an interface if any valid
47+
way of passing parameters to the interface method is also valid for the
48+
implementation.
49+
50+
Consequently, the following differences are allowed between the signature
51+
of an implementation method and the signature of its interface definition:
52+
53+
1. An implementation may add new arguments to an interface iff:
54+
a. All new arguments have default values.
55+
b. All new arguments accepted positionally (i.e. all non-keyword-only
56+
arguments) occur after any arguments declared by the interface.
57+
c. Keyword-only arguments may be reordered by the implementation.
58+
59+
2. For type-annotated interfaces, type annotations my differ as follows:
60+
a. Arguments to implementations of an interface may be annotated with
61+
a **superclass** of the type specified by the interface.
62+
b. The return type of an implementation may be annotated with a
63+
**subclass** of the type specified by the interface.
64+
"""
65+
# Unwrap to get the underlying inspect.Signature objects.
66+
return all(
67+
[
68+
positionals_compatible(
69+
takewhile(is_positional, impl_sig.parameters.values()),
70+
takewhile(is_positional, iface_sig.parameters.values()),
71+
check_annotations=check_annotations,
72+
),
73+
keywords_compatible(
74+
valfilter(complement(is_positional), impl_sig.parameters),
75+
valfilter(complement(is_positional), iface_sig.parameters),
76+
check_annotations=check_annotations,
77+
),
78+
]
79+
)
80+
81+
82+
_POSITIONALS = frozenset([Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD])
83+
84+
85+
def is_positional(arg):
86+
return arg.kind in _POSITIONALS
87+
88+
89+
def has_default(arg):
90+
"""Does ``arg`` provide a default?."""
91+
return arg.default is not Parameter.empty
92+
93+
94+
def params_compatible(impl, iface, check_annotations=True):
95+
if impl is None:
96+
return False
97+
98+
if iface is None:
99+
return has_default(impl)
100+
101+
checks = (
102+
impl.name == iface.name
103+
and impl.kind == iface.kind
104+
and has_default(impl) == has_default(iface)
105+
)
106+
107+
if check_annotations:
108+
checks = checks and annotations_compatible(impl, iface)
109+
110+
return checks
111+
112+
113+
def positionals_compatible(impl_positionals, iface_positionals, check_annotations=True):
114+
params_compat = partial(params_compatible, check_annotations=check_annotations)
115+
return all(
116+
starmap(
117+
params_compat,
118+
zip_longest(impl_positionals, iface_positionals),
119+
)
120+
)
121+
122+
123+
def keywords_compatible(impl_keywords, iface_keywords, check_annotations=True):
124+
params_compat = partial(params_compatible, check_annotations=check_annotations)
125+
return all(starmap(params_compat, dzip(impl_keywords, iface_keywords).values()))
126+
127+
128+
def annotations_compatible(impl, iface):
129+
"""Check whether the type annotations of an implementation are compatible with
130+
the annotations of the interface it implements.
131+
"""
132+
return impl.annotation == iface.annotation

0 commit comments

Comments
 (0)