From e332ec1c96fb0a3d2b2a8ded449989831185c8d8 Mon Sep 17 00:00:00 2001 From: Muhammad Hamid Date: Thu, 29 Oct 2026 07:08:44 +0500 Subject: [PATCH 1/3] feat(mypy): infer Exception error type for unit methods on *E aliases (#407) --- returns/contrib/mypy/_consts.py | 54 ++++++++++++++ returns/contrib/mypy/_features/alias_e.py | 70 +++++++++++++++++++ returns/contrib/mypy/returns_plugin.py | 2 + .../test_ioresult_typecast.yml | 9 +++ 4 files changed, 135 insertions(+) create mode 100644 returns/contrib/mypy/_features/alias_e.py diff --git a/returns/contrib/mypy/_consts.py b/returns/contrib/mypy/_consts.py index 69313b8d0..1eef2d7ff 100644 --- a/returns/contrib/mypy/_consts.py +++ b/returns/contrib/mypy/_consts.py @@ -34,3 +34,57 @@ 'returns.future.FutureResult.do', ) """Used for :ref:`do-notation`.""" + +E_ALIAS_METHODS: Final = ( + # ``Result`` unit constructors: + 'returns.result.Result.from_value', + 'returns.result.Result.from_failure', + 'returns.result.Result.from_result', + # ``IOResult`` unit constructors: + 'returns.io.IOResult.from_typecast', + 'returns.io.IOResult.from_failed_io', + 'returns.io.IOResult.from_io', + 'returns.io.IOResult.from_result', + 'returns.io.IOResult.from_ioresult', + 'returns.io.IOResult.from_value', + 'returns.io.IOResult.from_failure', + # ``FutureResult`` unit constructors: + 'returns.future.FutureResult.from_typecast', + 'returns.future.FutureResult.from_future', + 'returns.future.FutureResult.from_failed_future', + 'returns.future.FutureResult.from_future_result', + 'returns.future.FutureResult.from_io', + 'returns.future.FutureResult.from_failed_io', + 'returns.future.FutureResult.from_ioresult', + 'returns.future.FutureResult.from_result', + 'returns.future.FutureResult.from_value', + 'returns.future.FutureResult.from_failure', + # ``RequiresContextResult`` unit constructors: + 'returns.context.requires_context_result.RequiresContextResult.from_result', + 'returns.context.requires_context_result.RequiresContextResult.from_typecast', + 'returns.context.requires_context_result.RequiresContextResult.from_value', + 'returns.context.requires_context_result.RequiresContextResult.from_failure', + # ``RequiresContextIOResult`` unit constructors: + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_result', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_io', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_failed_io', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_ioresult', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_typecast', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_value', + 'returns.context.requires_context_ioresult.RequiresContextIOResult.from_failure', + # ``RequiresContextFutureResult`` unit constructors: + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_result', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_io', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_failed_io', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_ioresult', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_future', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_failed_future', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_future_result', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_typecast', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_value', + 'returns.context.requires_context_future_result.RequiresContextFutureResult.from_failure', +) +"""Used for ``*E`` aliases unit methods.""" + +E_ALIAS_ANALYZER: Final = 'returns.contrib.mypy._features.alias_e.analyze' +"""Used for ``*E`` aliases unit methods.""" diff --git a/returns/contrib/mypy/_features/alias_e.py b/returns/contrib/mypy/_features/alias_e.py new file mode 100644 index 000000000..7ae6c882e --- /dev/null +++ b/returns/contrib/mypy/_features/alias_e.py @@ -0,0 +1,70 @@ +""" +Fix for ``*E`` aliases unit methods: ``ResultE``, ``IOResultE``, etc. + +Problem: ``IOResultE`` is an alias for ``IOResult[_ValueType_co, Exception]``. +When calling a unit method on the alias, like ``IOResultE.from_value(1)``, +``mypy`` returns ``IOResult[int, Any]`` instead of ``IOResult[int, Exception]``, +because unit constructors use ``Any`` for the type they cannot infer. + +This module rewrites the return type of unit constructors +called on ``*E`` aliases: positions where the alias fixes ``Exception`` +and the constructor returned ``Any`` are corrected to ``Exception``. + +Important: we don't do anything ugly here. +We only solve problems of the current typing implementation. + +Related issue: https://github.com/dry-python/returns/issues/407 +""" + +from mypy.plugin import MethodContext +from mypy.types import ( + AnyType, + CallableType, + Instance, + get_proper_type, +) +from mypy.types import Type as MypyType + +_EXCEPTION_FULLNAME = 'builtins.Exception' +"""Fullname of the type ``*E`` aliases fix their error type to.""" + + +def analyze(ctx: MethodContext) -> MypyType: + """ + Rewrites return type of a unit constructor called on an ``*E`` alias. + + For example, ``IOResultE.from_value(1)`` is rewritten from + ``IOResult[int, Any]`` to ``IOResult[int, Exception]``. + """ + self_type = ctx.type + if not isinstance(self_type, CallableType): + return ctx.default_return_type + + bound_type = get_proper_type(self_type.ret_type) + if not isinstance(bound_type, Instance): + return ctx.default_return_type + + default_ret = get_proper_type(ctx.default_return_type) + assert isinstance(default_ret, Instance) + if default_ret.type != bound_type.type: + # Different containers, nothing to fix. + return ctx.default_return_type + + new_args = list(default_ret.args) + changed = False + for index, (bound_arg, ret_arg) in enumerate(zip(bound_type.args, new_args)): + proper_bound_arg = get_proper_type(bound_arg) + proper_ret_arg = get_proper_type(ret_arg) + if ( + isinstance(proper_bound_arg, Instance) + and proper_bound_arg.type.fullname == _EXCEPTION_FULLNAME + and isinstance(proper_ret_arg, AnyType) + ): + # The alias fixes this position to `Exception`, + # but the constructor could not infer it and used `Any`. + new_args[index] = proper_bound_arg + changed = True + + if not changed: + return ctx.default_return_type + return Instance(default_ret.type, new_args, default_ret.line) diff --git a/returns/contrib/mypy/returns_plugin.py b/returns/contrib/mypy/returns_plugin.py index 0d7772eb5..80e688c27 100644 --- a/returns/contrib/mypy/returns_plugin.py +++ b/returns/contrib/mypy/returns_plugin.py @@ -26,6 +26,7 @@ from returns.contrib.mypy import _consts from returns.contrib.mypy._features import ( + alias_e, curry, do_notation, flow, @@ -75,6 +76,7 @@ class _ReturnsPlugin(Plugin): _consts.TYPED_KIND_KINDED_CALL: kind.kinded_call, _consts.TYPED_KIND_KINDED_GET: kind.kinded_get_descriptor, **dict.fromkeys(_consts.DO_NOTATION_METHODS, do_notation.analyze), + **dict.fromkeys(_consts.E_ALIAS_METHODS, alias_e.analyze), } def get_function_hook( diff --git a/typesafety/test_io/test_ioresult_container/test_ioresult_typecast.yml b/typesafety/test_io/test_ioresult_container/test_ioresult_typecast.yml index 730b74b88..6daca2404 100644 --- a/typesafety/test_io/test_ioresult_container/test_ioresult_typecast.yml +++ b/typesafety/test_io/test_ioresult_container/test_ioresult_typecast.yml @@ -133,3 +133,12 @@ result: IOResultE[int] = function(1) reveal_type(result) # N: Revealed type is "returns.io.IOResult[int, Exception]" + + +- case: ioresulte_unit_methods + disable_cache: false + main: | + from returns.io import IOResultE + + reveal_type(IOResultE.from_value(1)) # N: Revealed type is "returns.io.IOResult[int, Exception]" + reveal_type(IOResultE.from_failure(ValueError('boom'))) # N: Revealed type is "returns.io.IOResult[Any, ValueError]" From 19a92014385384c45ee2790b6712c18347aba6b3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:09:40 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- returns/contrib/mypy/_features/alias_e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/returns/contrib/mypy/_features/alias_e.py b/returns/contrib/mypy/_features/alias_e.py index 7ae6c882e..f13dab76d 100644 --- a/returns/contrib/mypy/_features/alias_e.py +++ b/returns/contrib/mypy/_features/alias_e.py @@ -52,7 +52,9 @@ def analyze(ctx: MethodContext) -> MypyType: new_args = list(default_ret.args) changed = False - for index, (bound_arg, ret_arg) in enumerate(zip(bound_type.args, new_args)): + for index, (bound_arg, ret_arg) in enumerate( + zip(bound_type.args, new_args) + ): proper_bound_arg = get_proper_type(bound_arg) proper_ret_arg = get_proper_type(ret_arg) if ( From 5cc6eb941272de0133f92d099abfe0609e86b48d Mon Sep 17 00:00:00 2001 From: Muhammad Hamid Date: Thu, 29 Oct 2026 08:08:38 +0500 Subject: [PATCH 3/3] style(mypy): reduce cognitive complexity and specify strict zip in alias_e --- returns/contrib/mypy/_features/alias_e.py | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/returns/contrib/mypy/_features/alias_e.py b/returns/contrib/mypy/_features/alias_e.py index f13dab76d..e72dc0635 100644 --- a/returns/contrib/mypy/_features/alias_e.py +++ b/returns/contrib/mypy/_features/alias_e.py @@ -16,6 +16,8 @@ Related issue: https://github.com/dry-python/returns/issues/407 """ +from collections.abc import Sequence + from mypy.plugin import MethodContext from mypy.types import ( AnyType, @@ -50,10 +52,21 @@ def analyze(ctx: MethodContext) -> MypyType: # Different containers, nothing to fix. return ctx.default_return_type - new_args = list(default_ret.args) + new_args, changed = _rewrite_args(bound_type.args, list(default_ret.args)) + if not changed: + return ctx.default_return_type + return Instance(default_ret.type, new_args, default_ret.line) + + +def _rewrite_args( + bound_args: Sequence[MypyType], + default_args: list[MypyType], +) -> tuple[list[MypyType], bool]: + """Replaces uninferable Any types with Exception when fixed by alias.""" + new_args = list(default_args) changed = False for index, (bound_arg, ret_arg) in enumerate( - zip(bound_type.args, new_args) + zip(bound_args, new_args, strict=True), ): proper_bound_arg = get_proper_type(bound_arg) proper_ret_arg = get_proper_type(ret_arg) @@ -62,11 +75,6 @@ def analyze(ctx: MethodContext) -> MypyType: and proper_bound_arg.type.fullname == _EXCEPTION_FULLNAME and isinstance(proper_ret_arg, AnyType) ): - # The alias fixes this position to `Exception`, - # but the constructor could not infer it and used `Any`. new_args[index] = proper_bound_arg changed = True - - if not changed: - return ctx.default_return_type - return Instance(default_ret.type, new_args, default_ret.line) + return new_args, changed