Skip to content

fix(types): stop the deprecation decorators erasing wrapped signatures - #739

Merged
vishal-bala merged 4 commits into
mainfrom
fix/deprecation-decorator-signature-erasure
Oct 2, 2026
Merged

vishal-bala merged 4 commits into
mainfrom
fix/deprecation-decorator-signature-erasure

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

The three deprecation decorators in redisvl/utils/utils.py returned a bare Callable and wrapped their target in an untyped *args/**kwargs closure. A bare Callable means Callable[..., Any], so every decorated function lost its signature: mypy accepted any arguments at any call site, treated every return value as Any, and skipped the override checks those methods took part in, because Callable[..., Any] is compatible with any signature.

That covered 47 @deprecated_argument applications across redisvl/, 36 of them on the vectorizer embed/embed_many surface, plus 10 more between deprecated_function and deprecated_class. Both SearchIndex constructors were among them. make check-types passed the whole time.

The hole was found by accident. Removing @deprecated_argument from SearchIndex.__init__ and AsyncSearchIndex.__init__ immediately surfaced four arg-type errors in redisvl/extensions/cache/llm/semantic.py that had been invisible for as long as the decorator had been applied.

Changes

Retype the decorators with ParamSpec

deprecated_argument, deprecated_function and deprecated_class now preserve what they wrap. A ParamSpec carries the parameter list and a TypeVar carries the return type, so the decorated name keeps the signature it had:

P = ParamSpec("P")
R = TypeVar("R")

def deprecated_argument(
    argument: str, replacement: str | None = None
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...

The wrapper is annotated *args: P.args, **kwargs: P.kwargs to tie it to the same parameter list. deprecated_class takes C = TypeVar("C", bound=type) and returns Callable[[C], C], so a decorated class keeps its own type and the decorator is checked as a class decorator. The classmethod/staticmethod branch needs two cast calls, because a classmethod object is not a Callable to the type system; that branch covers the decorator ordering the docstring documents as unsupported.

Restoring the signatures surfaced 52 pre-existing errors. The retyping is committed on its own so the diff stays readable, which means check-types fails on that commit and passes on the next.

Give the vectorizer provider hooks the signature they are called with

BaseVectorizer._embed declared text as its first parameter and content as its second, while embed() dispatches positionally as self._embed(content, **kwargs). Every provider in the tree happens to name its first parameter content, so this worked; an implementation that followed the base signature instead would have silently received the content in text.

The four hooks (_embed, _embed_many, _aembed, _aembed_many) are internal extension points, and the public methods resolve the deprecated text/texts alias before dispatching to them. Nothing in the tree ever calls a hook with the alias. They now take content first and required, with no alias, and the dead alias parameters and @deprecated_argument decorators are gone from the five providers that carried them:

# before
def _embed(self, text: Any = "", content: Any = "", **kwargs) -> list[float]: ...
# after
def _embed(self, content: Any, **kwargs) -> list[float]: ...

embed_many and aembed_many dispatched the batch hooks by keyword while embed and aembed dispatched positionally. All four are positional now, so one contract covers the set: the content is always the first positional argument, which leaves a provider free to name that parameter whatever it likes.

Delete the redundant overrides on the deprecated alias classes

CustomTextVectorizer, BedrockTextVectorizer, VertexAITextVectorizer and VoyageAITextVectorizer each re-implemented embed and embed_many to merge text into content and warn, which BaseVectorizer already does identically. In doing so they narrowed the contract they existed to preserve. They dropped preprocess, batch_size, as_buffer and skip_cache from the positional signature, so BedrockTextVectorizer().embed("hi", None, fn) raised TypeError where the parent works, and most of them annotated a list[float] return that is bytes whenever as_buffer=True.

The overrides are deleted. Each alias now inherits the parent's full, honestly typed methods, and still warns on both the class and the text keyword.

Type BaseCache.redis_kwargs

One of the 52 errors was outside the vectorizer hierarchy. redis_kwargs was an inferred heterogeneous dict, which is why BaseCache cast on every read of it and why SemanticCache could pass its values into SearchIndex unchecked. A RedisConnectionKwargs TypedDict removes the four casts. semantic.py is unchanged and now type-checks as it stands.

Release Notes

Type checkers now see real signatures for the vectorizer embed and embed_many methods, both SearchIndex constructors, SemanticCache, SemanticRouter and MessageHistory, all of which the deprecation decorators previously erased. Calls passing arguments those functions do not accept may newly fail type checking.

Notes

Anyone subclassing BaseVectorizer outside this repository to implement the private hooks is affected in one direction only. A subclass whose first parameter is named content keeps working, whatever else it declares, and so does one that names it something else, because every dispatch is positional. A subclass that declared the base's old (text, content) order was already receiving the content in text and is now flagged by a type checker rather than failing silently. The documented way to supply your own embedding function is CustomVectorizer, which is untouched.

Dropping the alias parameters changes one edge. _embed_many([]) used to resolve [] or None to None and raise TypeError; it now returns []. The public embed_many and aembed_many short-circuit empty input before dispatching, so this is unreachable from the public API.

Verified locally on Python 3.13 with all extras installed: mypy ./redisvl clean across 120 source files, 1524 unit tests, 63 LLM-cache integration tests, and 34 vectorizer integration tests with the 75 that need provider API keys skipped. No test file is modified by this change, so the passing suite is a behaviour-preservation signal rather than tests adapted to new behaviour. The pre-existing mypy error at redisvl/index/storage.py:630 did not reproduce in this environment and is untouched either way.


Note

Medium Risk
Mostly static typing and internal hook contracts; alias-class overrides removal fixes prior signature bugs but may change type-checker results for consumers relying on erased signatures.

Overview
Deprecation decorators (deprecated_argument, deprecated_function, deprecated_class) are retyped with ParamSpec and return-type TypeVars so wrapped callables keep their real signatures instead of collapsing to untyped Callable. deprecated_class uses getattr/setattr on __init__ for mypy-friendly class decoration.

Vectorizer internals: BaseVectorizer’s _embed / _embed_many / _aembed / _aembed_many hooks now take required content as the first positional argument only—deprecated text/texts handling stays on the public embed* methods. Batch hooks are invoked positionally (not contents= keyword). Provider implementations drop duplicate @deprecated_argument and alias parameters on those private methods.

Deprecated alias classes (BedrockTextVectorizer, CustomTextVectorizer, VertexAITextVectorizer, VoyageAITextVectorizer) no longer override embed/embed_many with narrowed signatures; they inherit BaseVectorizer’s full API while text/texts still warn on the public surface.

Reviewed by Cursor Bugbot for commit da3f290. Bugbot is set up for automated code reviews on this repo. Configure here.

deprecated_argument, deprecated_function and deprecated_class all returned
a bare Callable and wrapped the target in an untyped *args/**kwargs closure.
That erased the signature of every decorated function, so mypy silently
stopped checking their call sites and every override relationship they took
part in — 47 @deprecated_argument applications across redisvl/, 36 of them on
the vectorizer embed/embed_many surface, plus 10 more between
deprecated_function and deprecated_class. Both SearchIndex constructors are
among them.

Retype the three decorators with ParamSpec so each wrapper keeps the wrapped
signature, and give deprecated_class a type-bound TypeVar so it is checked as
a class decorator.

This commit is the retyping alone; mypy now reports the pre-existing errors it
had been hiding, which the next commit fixes.
With the deprecation decorators now signature-preserving, mypy checks the
vectorizer override hierarchy again and reports 52 pre-existing errors. Two
genuine defects were behind them.

BaseVectorizer._embed declared `text` as its first parameter and `content` as
its second, but embed() dispatches positionally — `self._embed(content, ...)`.
Every provider happens to name its first parameter `content`, so this worked;
any implementation that followed the base signature instead would have silently
received the content in `text`. The four provider hooks (_embed, _embed_many,
_aembed, _aembed_many) are internal extension points: the public methods
already resolve the deprecated `text`/`texts` alias before dispatching, and
nothing in the codebase ever calls them with it. Give them the signature they
are actually called with — content first and required, no alias — and drop the
now-dead alias parameters and @deprecated_argument decorators from the five
providers that carried them. embed_many/aembed_many dispatched the batch hooks
by keyword while embed/aembed dispatched positionally; make all four
positional so one contract covers the set.

One behavioural edge follows from dropping the aliases: `_embed_many([])` used
to resolve `[] or None` to None and raise TypeError, and now returns []. The
public embed_many/aembed_many short-circuit empty input before dispatching, so
this is unreachable from the public API.

The four deprecated *TextVectorizer aliases each re-implemented embed and
embed_many purely to merge `text` into `content` and warn, which BaseVectorizer
already does identically. In doing so they narrowed the contract: they dropped
preprocess/batch_size/as_buffer/skip_cache from the positional signature, so
`BedrockTextVectorizer().embed("hi", None, fn)` raised TypeError where the
parent works, and most of them annotated a `list[float]` return that is bytes
whenever as_buffer=True. Delete the redundant overrides; the aliases inherit
correct, honestly typed methods and still warn on both the class and the `text`
kwarg.

One of the 52 errors was outside the vectorizer hierarchy: BaseCache.redis_kwargs
was an inferred heterogeneous dict, so BaseCache cast on every read of it and
SemanticCache passed its values into SearchIndex unchecked. Typing it as a
TypedDict removes the four casts; semantic.py needs no change and now
type-checks as it stands.
@vishal-bala
vishal-bala marked this pull request as ready for review September 28, 2026 12:33
@vishal-bala vishal-bala added the auto:internal Changes only affect the internal API label Sep 28, 2026
One conflict, in the import block of redisvl/extensions/cache/base.py. main
dropped the collections.abc.Mapping import when clear()/aclear() moved onto
scan_iter; this branch replaced the typing.cast import with TypedDict when
redis_kwargs gained a type. Neither Mapping nor cast is referenced after the
merge, so the resolution keeps `from typing import Any, TypedDict` alone.

@limjoobin limjoobin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. This PR ensures the deprecation decorators remain consistent with their real signatures for mypy type safety checking by retyping them with ParamSpec/TypeVar instead of Callable.

Just a few merge conflicts to resolve and this PR is good to go!

One conflict, in redisvl/extensions/cache/base.py, resolved by taking main's
version of the file. main (#726, #727) independently typed BaseCache.redis_kwargs
as a TypedDict, _CacheConnectionKwargs, with the same three fields, and removed
the same four casts. This branch's RedisConnectionKwargs was the only change it
made to that file, so keeping both would have left two identical types.

utils.py merged cleanly: main reworded the three deprecation messages ("a
future release" rather than "the next major release"), and the ParamSpec
retyping of the same functions is untouched.
@vishal-bala
vishal-bala merged commit ddbbafa into main Oct 2, 2026
58 checks passed
@vishal-bala
vishal-bala deleted the fix/deprecation-decorator-signature-erasure branch October 2, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:internal Changes only affect the internal API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants