Skip to content

Fix ParamSpec inference for generic descriptors - #4878

Closed
tobyh-canva wants to merge 3 commits into
facebook:mainfrom
tobyh-canva:fix/4844
Closed

tobyh-canva wants to merge 3 commits into
facebook:mainfrom
tobyh-canva:fix/4844

Conversation

@tobyh-canva

@tobyh-canva tobyh-canva commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Pyrefly handled some ParamSpec argument lists in the wrong direction. This caused Pyrefly to accept valid descriptor access (false-positive bad-argument-type / no-matching-overload) and accept unsafe callback replacements (false-negative bad-assignment / bad-argument-type).

Fixes #4844.

Example

Before

Pyrefly rejected valid instance access for generic descriptors that use Concatenate. It did not recognise that descriptor binding changes:

(self, value: int)

to:

(value: int)

This affected descriptor APIs such as @weave.op().

Pyrefly also accepted unsafe callback replacements. A replacement could remove keyword support from a callback. A later valid keyword call could then fail at runtime.

After

Pyrefly removes the receiver parameter when it binds these descriptors. It keeps the remaining parameter types, names, and keyword support.

Pyrefly also rejects callback replacements that would make an existing valid call unsafe.

Typing behaviour

A ParamSpec describes function inputs. Input compatibility runs in the opposite direction to return-type compatibility. Pyrefly now uses this direction when it checks a ParamSpec inside a generic class.

Pyrefly now infers variance correctly when a generic class refers to itself. An incomplete result from the recursive reference no longer overrides direct evidence from methods and fields.

A writable callable field supports both reads and writes. Pyrefly therefore treats its type parameters as invariant. This prevents an unsafe callback replacement.

Test Plan

I've added two regression tests covering #4844:

  • One replicates the false-positive no-matching-overload observed with @weave.op().
  • The other is reduced down to a simpler false-positive bad-argument-type.

I also added a control test to ensure this fix doesn't over-correct and introduce false-negative errors. That's actually how I found the 2nd bug related to callback replacement 😅

Conformance

Resolves 13 outstanding conformance differences (reduced from 42 to 29).


I used Codex to help draft this PR :)

@meta-codesync

meta-codesync Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

This pull request has been imported. If you are a Meta employee, you can view this in D119452786. (Because this pull request was imported automatically, there will not be any future comments.)

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added size/l and removed size/l labels Sep 10, 2026
@tobyh-canva

Copy link
Copy Markdown
Collaborator Author

According to ChatGPT, all the mypy_primer findings are expected. This is what it said:

  • Home Assistant’s 19 removed errors are expected. HassJob holds a read-only callback, so compatible callback parameters and return types should be accepted. These were false positives. See HassJob and the callable assignability rules.
  • hydra-zen’s removed error is also expected. Partial.__call__ is a writable protocol attribute, so keeping its type variable invariant is correct under protocol variance rules.

@tobyh-canva
tobyh-canva marked this pull request as ready for review September 10, 2026 07:15
@github-actions

Copy link
Copy Markdown

Diff from mypy_primer, showing the effect of this PR on open source code:

============================================================
SUMMARY
============================================================
Total: +0 new errors, -20 fixed errors
By preset: +0/-20 (default), +0/-20 (strict)

Projects with changes (2):
  hydra-zen: +0 -1
  core: +0 -19
============================================================

FULL DIFF DETAILS
------------------------------------------------------------

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
- ERROR src/hydra_zen/typing/_implementations.py:85:7-14: Type variable `T2` in class `Partial` is declared as invariant, but could be covariant based on its usage [variance-mismatch]

core (https://github.com/home-assistant/core)
- ERROR homeassistant/auth/__init__.py:566:13-37: Argument `HassJob[[_: datetime | None = None], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_track_point_in_utc_time` [bad-argument-type]
- ERROR homeassistant/components/bluetooth/__init__.py:206:13-31: Argument `HassJob[[now: datetime], Coroutine[Unknown, Unknown, None]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/bond/entity.py:200:13-53: Argument `HassJob[[now: datetime], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/cloud/client.py:195:50-66: Argument `HassJob[[_: Any], Coroutine[Unknown, Unknown, None]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/google_assistant/report_state.py:64:44-61: Argument `HassJob[[now: Unknown | None = None], Coroutine[Unknown, Unknown, Unknown]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/google_assistant/report_state.py:143:44-61: Argument `HassJob[[now: Unknown | None = None], Coroutine[Unknown, Unknown, Unknown]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/harmony/select.py:73:39-58: Argument `HassJob[[activity_info: tuple[Unknown, ...]], None]` is not assignable to parameter `activity_starting` with type `HassJob[[tuple[Unknown, ...]], Any] | None` in function `homeassistant.components.harmony.subscriber.HarmonyCallback.__new__` [bad-argument-type]
- ERROR homeassistant/components/harmony/select.py:74:38-57: Argument `HassJob[[activity_info: tuple[Unknown, ...]], None]` is not assignable to parameter `activity_started` with type `HassJob[[tuple[Unknown, ...]], Any] | None` in function `homeassistant.components.harmony.subscriber.HarmonyCallback.__new__` [bad-argument-type]
- ERROR homeassistant/components/hdmi_cec/__init__.py:189:51-72: Argument `HassJob[[now: Unknown | None = None], Unknown]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` [bad-argument-type]
- ERROR homeassistant/components/hdmi_cec/__init__.py:200:64-85: Argument `HassJob[[now: Unknown | None = None], Unknown]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/ld2410_ble/coordinator.py:72:46-72: Argument `HassJob[[_now: datetime], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/nasweb/coordinator.py:184:13-22: Argument `HassJob[[now: datetime], Coroutine[Unknown, Unknown, None]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_at` [bad-argument-type]
- ERROR homeassistant/components/onvif/event_manager.py:515:35-58: Argument `HassJob[[_now: datetime | None = None], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/reolink/host.py:852:56-70: Argument `HassJob[[*_: Any], Coroutine[Unknown, Unknown, None]]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/components/ssdp/websocket_api.py:67:78-81: Argument `HassJob[[info: SsdpServiceInfo, change: SsdpChange], None]` is not assignable to parameter `callback` with type `HassJob[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None]` in function `homeassistant.components.ssdp.scanner.Scanner.async_register_callback` [bad-argument-type]
- ERROR homeassistant/components/tts/__init__.py:700:13-29: Argument `HassJob[[_now: datetime], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/helpers/chat_session.py:83:13-29: Argument `HassJob[[now: datetime], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `homeassistant.helpers.event.async_call_later` [bad-argument-type]
- ERROR homeassistant/helpers/event.py:1447:48-57: Argument `HassJob[[utc_now: datetime], None]` is not assignable to parameter `action` with type `((datetime) -> Coroutine[Any, Any, None] | None) | HassJob[[datetime], Coroutine[Any, Any, None] | None]` in function `async_track_point_in_utc_time` [bad-argument-type]
- ERROR homeassistant/helpers/trigger.py:1583:34-50: Cannot set item in `dict[object, tuple[HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None] | Any], dict[str, Any]]]` [unsupported-operation]

@kinto0 kinto0 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.

Review automatically exported from Phabricator review in Meta.

@kinto0

kinto0 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

thanks! looks great at first glance, but it will take a sec to review it. I'll be splitting it into 3 diffs for review:
bug 1 Example().field triggers Descriptor.get: two diffs

  • Variance flip (subset.rs, lines 2919–2924)
  • Generic residuals are fallback-only (solver.rs, lines 1743–1756)

bug 2 false negative on unsafe callback replacement: one diff

@kinto0 kinto0 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.

looks great to me! sorry we missed this for 1.3, but I'm sure we'll have a pre-release out soon enough

I'll get another set of eyes from someone who understands variance better than me before merging

I've already split it so aside from anything else I tell you about I'll resolve all follow-ups on our end

}

/// Whether a bound is a placeholder for deferred generic callable structure.
fn is_generic_callable_residual(ty: &Type) -> bool {

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.

nit: I think we prefer this defined in crates/pyrefly_types/src/callable_residual.rs

@stroxler stroxler 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.

Review automatically exported from Phabricator review in Meta.

@kinto0

kinto0 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

rebasing and merging

meta-codesync Bot pushed a commit that referenced this pull request Sep 15, 2026
Summary:
ParamSpec arguments describe callable inputs, so their internal ordering is already contravariant. Applying class parameter variance directly reverses compatibility. Flip covariant and contravariant ParamSpec arguments before comparing generic class arguments. This corrects ParamSpec conformance behavior and provides the directional half required for generic descriptor binding.

First of three commits split from the original change.

Part of #4878 by Toby Harradine <tobyh@canva.com>.

Reviewed By: stroxler

Differential Revision: D119452786

fbshipit-source-id: 22377f15b5f15e4e7334008ae46f31429b6799f7
meta-codesync Bot pushed a commit that referenced this pull request Sep 15, 2026
Summary:
Generic callable residuals preserve deferred ParamSpec structure while inference is incomplete. Treating them as concrete bounds lets placeholders conflict with or override later concrete evidence.

Keep generic residuals as fallback-only bounds during consistency checks, bound solving, and implicit-literal promotion. Concrete bounds now win once available, allowing descriptor binding to infer the remaining parameter list.

Second of three commits split from the original change.

Part of #4878 by Toby Harradine <tobyh@canva.com>.

Reviewed By: stroxler

Differential Revision: D119739484

fbshipit-source-id: 083077e2b02ba5c928b59582d39d4359ed48316f
meta-codesync Bot pushed a commit that referenced this pull request Sep 15, 2026
Summary:
Variance reached through a recursively referenced generic can be incomplete. Combining that fallback with direct evidence lets the recursive result override method and field uses. Writable callable fields were also treated as covariant even though both reads and writes make them invariant.

Track reliable and fallback evidence separately, prefer direct evidence when available, and infer invariant variance for writable callable fields. This preserves generic descriptor binding while rejecting callback replacements that remove valid calling forms.

Third of three commits split from the original change.

Part of #4878 by Toby Harradine <tobyh@canva.com>.

Reviewed By: stroxler

Differential Revision: D119739486

fbshipit-source-id: 97f5087eda2c7a4bcd11d271a67f4783220d4c9e
@stroxler

Copy link
Copy Markdown
Contributor

Hmm. I think this got merged, but the PR linkage was somehow lost so the PR didn't auto-close via ShipIt

Thanks @tobyh-canva!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(typechecking): false-positive bad-argument-type when resolving a generic descriptor using Concatenate

3 participants