Skip to content

Fix partial mypy plugin: make positionals keyword-only after a keyword-bound positional (#2191)#2446

Open
apoorvdarshan wants to merge 3 commits into
dry-python:masterfrom
apoorvdarshan:fix/partial-keyword-positional-kwonly-2191
Open

Fix partial mypy plugin: make positionals keyword-only after a keyword-bound positional (#2191)#2446
apoorvdarshan wants to merge 3 commits into
dry-python:masterfrom
apoorvdarshan:fix/partial-keyword-positional-kwonly-2191

Conversation

@apoorvdarshan

Copy link
Copy Markdown

Summary

returns.curry.partial's mypy plugin produced an unsound signature when a
positional parameter was applied by keyword. For example:

def foo(x: int, y: int) -> None: ...

bar = partial(foo, x=1)
reveal_type(bar)  # was: "def (y: int)"  -> should be: "def (*, y: int)"
bar(2)            # accepted by mypy, but raises at runtime:
                  # TypeError: foo() got multiple values for argument 'x'

Because partial is backed by functools.partial, bound arguments are
prepended and bound keywords are merged. Once an earlier positional parameter
is bound by keyword, the remaining positional parameters can no longer be
reached positionally at runtime, they must be passed by keyword. The plugin
was copying them unchanged (still positional), so bar(2) type-checked but
failed at runtime.

Fix

In Functions.diff (returns/contrib/mypy/_typeops/transform_callable.py),
after determining which parameters were applied, we now detect the first
positional parameter that was applied by keyword (i.e. outside the leading
positional prefix that functools.partial fills). Every remaining positional
parameter after that "hole" is converted to keyword-only
(ARG_POS/ARG_OPT -> ARG_NAMED/ARG_NAMED_OPT). Parameters bound
positionally, and functions with no keyword hole, are unaffected.

The revealed type for the example above becomes def (*, y: int), and
bar(2) is now correctly reported as Too many positional arguments.

Tests / Verification

Added three regression cases to
typesafety/test_curry/test_partial/test_partial.yml:

  • partial_positional_arg_by_keyword_makes_rest_kwonly - the issue's case
    now reveals def (*, second: float) -> str.
  • partial_positional_arg_by_keyword_rejects_positional_call - a positional
    call on the partial is now a type error.
  • partial_middle_positional_arg_by_keyword_makes_rest_kwonly - only the
    parameters after the keyword hole become keyword-only; earlier positionals
    are preserved.

All three fail on master and pass with this change. The full
typesafety/test_curry and typesafety/test_pipeline suites pass (120
cases), and flake8 (wemake-python-styleguide) and ruff/ruff format are
clean on the changed files. Also manually verified at runtime that the new
keyword-only signature matches actual functools.partial behaviour.

Disclosure: prepared with AI assistance; reviewed and verified locally.

When a positional parameter of a function is applied by keyword via
`partial` (e.g. `partial(foo, x=1)` for `foo(x, y)`), the resulting
callable's remaining positional parameters can no longer be filled
positionally at runtime, because `functools.partial` prepends the bound
arguments and any positional call argument would collide with the
keyword-bound parameter (`TypeError: foo() got multiple values ...`).

The mypy plugin previously copied those parameters unchanged, inferring
`def (y)` instead of `def (*, y)`, so `bar(2)` type-checked but failed at
runtime. Now, once a positional parameter is applied by keyword (outside
the leading positional prefix), every subsequent positional parameter is
converted to keyword-only, so passing it positionally is correctly
reported as a type error.

Fixes dry-python#2191.

def diff(self) -> CallableType:
#: Kinds of arguments that can be passed positionally.
_positional_kinds: ClassVar[frozenset[ArgKind]] = frozenset((

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please, move this before the __init__

main: |
from returns.curry import partial

def two_args(first: int, second: float) -> str:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please, test the same with *, second: float :)

Comment thread CHANGELOG.md

- Add `mypy>=1.19,<1.22` support

### Bugfixes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should go under new ## WIP section, 0.28.0 was already released.

main: |
from returns.curry import partial

def two_args(first: int, second: float) -> str:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please, also add a test case with first: int, /, second: int, third: str, where you bind second as kw arg :)

intermediate_names: list[str | None],
) -> bool:
"""Tells whether an original argument was already applied."""
if arg.kind in {ARG_STAR, ARG_STAR2}:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we can move this to be a constant as well :)

@codspeed-hq

codspeed-hq Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 22 untouched benchmarks


Comparing apoorvdarshan:fix/partial-keyword-positional-kwonly-2191 (72787b1) with master (4a8f69d)

Open in CodSpeed

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (82ef3ef) to head (a31ef11).
⚠️ Report is 550 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##            master     #2446    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           80        81     +1     
  Lines         2485      2565    +80     
  Branches       437        44   -393     
==========================================
+ Hits          2485      2565    +80     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… kw-only tests

- Move the partial bugfix entry into a new '0.28.1 WIP' changelog section,
  since 0.28.0 is already released.
- Move '_positional_kinds' class var in 'Functions' before '__init__'.
- Extract the '{ARG_STAR, ARG_STAR2}' set into a module-level
  '_VARIADIC_KINDS' constant, reused in both '_keyword_hole_at' and
  '_was_applied'.
- Add typesafety case for an already keyword-only param
  ('first: int, *, second: float') bound by keyword.
- Add typesafety case for a positional-only param
  ('first: int, /, second: int, third: str') with 'second' bound by keyword.
@apoorvdarshan

Copy link
Copy Markdown
Author

Thanks for the review @sobolevn! I've addressed all five points in a31ef11:

  1. transform_callable.py — move _positional_kinds before __init__: In Functions, both class vars (_positional_kinds and _keyword_only_kinds) now come first, and __init__ follows them.

  2. test_partial.yml — test the same with *, second: float: Added partial_keyword_only_arg_stays_keyword_only, which binds first on def two_args(first: int, *, second: float) and asserts the result stays def (*, second: float) -> str.

  3. CHANGELOG.md — should be under a new WIP section: Moved the bugfix entry out of the released 0.28.0 and into a new ## 0.28.1 WIP section at the top (matching the project's ## <version> WIP convention). 0.28.0 now keeps only its released mypy>=1.19,<1.22 feature.

  4. test_partial.yml — add first: int, /, second: int, third: str binding second as kw arg: Added partial_positional_only_before_keyword_bound_arg. Binding second by keyword makes third keyword-only while the positional-only first (before the hole) stays positional-only, giving def (int, *, third: str) -> str (mypy renders the positional-only param without a name).

  5. transform_callable.py — move this to a constant as well: Extracted {ARG_STAR, ARG_STAR2} into a module-level _VARIADIC_KINDS constant, now reused in both _keyword_hole_at and _was_applied.

Verified locally: the full typesafety/test_curry/test_partial/ suite passes (16/16, incl. the two new cases) on mypy 1.20, mypy returns/contrib/mypy/ is clean, and ruff + flake8 pass on the changed file. The original #2191 fix behavior is unchanged.


#: Kinds of arguments that consume the leftover positional or keyword
#: arguments (``*args`` and ``**kwargs``) and therefore cannot be applied.
_VARIADIC_KINDS: frozenset[ArgKind] = frozenset((ARG_STAR, ARG_STAR2))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
_VARIADIC_KINDS: frozenset[ArgKind] = frozenset((ARG_STAR, ARG_STAR2))
_VARIADIC_KINDS: Final = frozenset((ARG_STAR, ARG_STAR2))

"""

#: Kinds of arguments that can be passed positionally.
_positional_kinds: ClassVar[frozenset[ArgKind]] = frozenset((

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So, maybe this should be a constant as well? It is strange that something a module level constant and something is a class level attr.


# `second` is already keyword-only, so binding the positional `first`
# by keyword leaves it keyword-only just as before:
reveal_type(partial(two_args, first=1)) # N: Revealed type is "def (*, second: float) -> str"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please, also add partial(two_args, second=2) case

@apoorvdarshan

Copy link
Copy Markdown
Author

Thanks for the follow-up review! Addressed all three in 72787b17:

  1. _VARIADIC_KINDS now uses your suggested form: _VARIADIC_KINDS: Final = frozenset((ARG_STAR, ARG_STAR2)) (imported Final from typing).
  2. Agreed it was inconsistent to mix module-level constants and class-level attrs. Promoted the two attrs I'd added on Functions to module-level Final constants next to _VARIADIC_KINDS_POSITIONAL_KINDS (frozenset) and _KEYWORD_ONLY_KINDS (the kind→kw-only mapping) — and updated their references. Behavior is unchanged.
  3. Added a partial(two_args, second=2) case (partial_last_arg_by_keyword_keeps_first_positional): binding the last parameter by keyword leaves no positional after the hole, so first stays positional → def (first: int) -> str.

Verified locally with the pinned mypy: mypy returns/contrib/mypy/ is clean, the full typesafety/test_curry/test_partial/ suite passes (incl. the new case), and ruff is clean on the changed file.

The earlier Run benchmarks (memory) red was a transient CodSpeed installer 504 (infra, not the change); this push should re-run CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants