Skip to content

Conversation

@sharkdp
Copy link
Contributor

@sharkdp sharkdp commented May 21, 2025

Summary

We create Callable types for synthesized functions like the __init__ method of a dataclass. These generated functions are real functions though, with descriptor-like behavior. That is, they can bind self when accessed on an instance. This was modeled incorrectly so far.

Test Plan

Updated tests

@sharkdp sharkdp added the ty Multi-file analysis & type inference label May 21, 2025
@github-actions
Copy link
Contributor

github-actions bot commented May 21, 2025

mypy_primer results

Changes were detected when running on open source projects
attrs (https://github.com/python-attrs/attrs)
- info[revealed-type] tests/dataclass_transform_example.py:13:13: Revealed type: `(a: str, b: int) -> None`
+ info[revealed-type] tests/dataclass_transform_example.py:13:13: Revealed type: `(self: Define, a: str, b: int) -> None`
- info[revealed-type] tests/dataclass_transform_example.py:21:13: Revealed type: `(with_converter: int = Unknown) -> None`
+ info[revealed-type] tests/dataclass_transform_example.py:21:13: Revealed type: `(self: DefineConverter, with_converter: int = Unknown) -> None`
- warning[possibly-unbound-attribute] tests/test_dunders.py:1008:13: Attribute `__code__` on type `Unknown | (() -> None)` is possibly unbound
- warning[possibly-unbound-attribute] tests/test_dunders.py:1020:13: Attribute `__code__` on type `Unknown | (() -> None)` is possibly unbound
- error[unresolved-attribute] tests/test_dunders.py:1032:13: Type `() -> None` has no attribute `__code__`
- Found 621 diagnostics
+ Found 618 diagnostics

@sharkdp sharkdp force-pushed the david/function-like-callables branch from 0016661 to 111b0a9 Compare May 21, 2025 14:00
@sharkdp sharkdp force-pushed the david/function-like-callables branch from 111b0a9 to 5ca8ace Compare May 21, 2025 14:05
@sharkdp sharkdp marked this pull request as ready for review May 21, 2025 14:14
@AlexWaygood
Copy link
Member

AlexWaygood commented May 21, 2025

- warning[possibly-unbound-attribute] tests/test_dunders.py:1008:13: Attribute `__code__` on type `Unknown | (() -> None)` is possibly unbound
+ warning[possibly-unbound-attribute] tests/test_dunders.py:1008:13: Attribute `__code__` on type `Unknown | ((self: C) -> None)` is possibly unbound

hmm, it seems incorrect that we're emitting this diagnostic. It definitely exists at runtime:

>>> def f(): ...
... 
>>> f.__code__
<code object f at 0x105d60b90, file "<python-input-0>", line 1>
>>> class Foo:
...     def f(): ...
...     
>>> Foo().f.__code__
<code object f at 0x105d60c60, file "<python-input-2>", line 2>

and it's annotated as existing on FunctionType instances in typeshed:

@final
class FunctionType:
@property
def __closure__(self) -> tuple[CellType, ...] | None: ...
__code__: CodeType

I suppose we need to fallback to Instance("FunctionType") for member access on CallableType inhabitants if is_function_like is set to true?

@AlexWaygood
Copy link
Member

Are CallableType inhabitants with is_function_like: true modeled as (consistent) subtypes of Instance("types.FunctionType")? I think they should be -- could you add a test?

@AlexWaygood
Copy link
Member

I guess I'm not totally sold that these should be modeled as a special case of CallableType rather than being modeled as a special case of FunctionLiteral (or being their own Type variant entirely) -- the subtyping/assignability and member-access questions seem to me like function-like CallableTypes behave pretty differently to non-function-like CallableTypes

@carljm
Copy link
Contributor

carljm commented May 21, 2025

I think it would be important to see some new subtyping and assignability tests (and the code to make them pass, which I also don't think I'm seeing here?), which should verify several things:

  • A fully-static callable type that is function-like is a subtype of the same callable type that is not function-like, but not the other way around. (Unless I'm missing something, it seems like this PR currently doesn't consider is_function_like in callable-subtyping at all, which I think would mean it fails to recognize the "not" part of this?)
  • Same for assignability.
  • A function literal is a subtype of its own into_callable_type (this could almost be a property test?)

Comment on lines +7570 to +7638
/// We use `CallableType` to represent function-like objects, like the synthesized methods
/// of dataclasses or NamedTuples. These callables act like real functions when accessed
/// as attributes on instances, i.e. they bind `self`.
Copy link
Contributor

@carljm carljm May 21, 2025

Choose a reason for hiding this comment

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

I think we could be a bit more precise in our description here, i.e. a callable type with is_function_like: True is inhabited by callable objects with the given signature that also have a __get__ method which acts like FunctionType.__get__ (returns a bound method).

@sharkdp
Copy link
Contributor Author

sharkdp commented May 21, 2025

```diff
+ warning[possibly-unbound-attribute] tests/test_dunders.py:1008:13: Attribute `__code__` on type `Unknown | ((self: C) -> None)` is possibly unbound

hmm, it seems incorrect that we're emitting this diagnostic. It definitely exists at runtime:

Yes, thanks! At runtime, it also exists for generated methods on dataclasses. This should be fixed now.

@sharkdp
Copy link
Contributor Author

sharkdp commented May 21, 2025

Are CallableType inhabitants with is_function_like: true modeled as (consistent) subtypes of Instance("types.FunctionType")? I think they should be -- could you add a test?

Yes, thanks. Added the test, but haven't fixed it yet.

I guess I'm not totally sold that these should be modeled as a special case of CallableType rather than being modeled as a special case of FunctionLiteral (or being their own Type variant entirely) -- the subtyping/assignability and member-access questions seem to me like function-like CallableTypes behave pretty differently to non-function-like CallableTypes

I originally planned it this way, but somehow accidentally did the "reverse" today and special-cased Type::Callable, and wondered why it was so much easier than I had anticipated 😄. I will re-evaluate this after quickly trying to implement subtyping with the existing approach.

@sharkdp
Copy link
Contributor Author

sharkdp commented May 21, 2025

I think it would be important to see some new subtyping and assignability tests (and the code to make them pass, which I also don't think I'm seeing here?), which should verify several things:

  • A fully-static callable type that is function-like is a subtype of the same callable type that is not function-like, but not the other way around. (Unless I'm missing something, it seems like this PR currently doesn't consider is_function_like in callable-subtyping at all, which I think would mean it fails to recognize the "not" part of this?)

  • Same for assignability.

So this is interesting. I added the tests, and they.. just pass. It works not because we're looking at the is_function_like attribute, but rather because the corresponding synthesized callable has positional-or-keyword parameters with names, whereas the equivalent callable type has positional-only parameters without any name.

type DunderInitType = TypeOf[C.__init__]  # (self: C, x: int) -> None
type EquivalentCallableType = Callable[[C, int], None]  # (C, int, /) -> None

This is probably not a great way to ensure proper subtyping/assignability properties, so I can look into incorporating is_function_like somehow.

@carljm
Copy link
Contributor

carljm commented May 21, 2025

I can look into incorporating is_function_like somehow

I think the implementation shouldn't be too difficult? Just a check in is_assignable_to_impl that the two is_function_like, if they differ, differ only in the right direction.

Writing a failing test may be more of the challenge, but I think if you also define EquivalentCallableType by writing an actual function and then using CallableTypeOf on it, that might work?

@AlexWaygood
Copy link
Member

somehow accidentally did the "reverse" today and special-cased Type::Callable, and wondered why it was so much easier than I had anticipated

it being "easier" doesn't necessarily reassure me ;) that might just mean that we've accidentally omitted some branches in some places that we would otherwise be forced to add if it was its own variant 😆

@carljm
Copy link
Contributor

carljm commented May 22, 2025

If we do have this as a form of Callable type, it might mean that we could be "smart" about method_attr: Callable[[Self], int] = function_object vs non_method_attr: Callable[[], int] = other_non_function_like_callable_object, where we actually use the type of the RHS as a hint to decide whether we consider the declaration to be of a "method descriptor" or "non method descriptor" callable type. That could let us avoid some unsoundness? See #18167 (comment)

On the other hand, it's weird to interpret the same annotation differently depending on the RHS. So maybe we shouldn't do that.

@sharkdp sharkdp force-pushed the david/function-like-callables branch from bb339b4 to 3e5f17d Compare May 23, 2025 12:11
@sharkdp sharkdp force-pushed the david/function-like-callables branch from 3e5f17d to eaab5d1 Compare May 23, 2025 12:12
@sharkdp sharkdp marked this pull request as ready for review May 23, 2025 12:28
@sharkdp
Copy link
Contributor Author

sharkdp commented May 23, 2025

Opening this up for review again. It passes all tests and resolves some ecosystem false positives. I am happy to spend (significantly?) more time on this, trying to rewrite it in terms of a customized FunctionType variant. I agree with @AlexWaygood that this might be less error-prone. But given that we currently only use this for one edge case (explicit calls to a synthetic dunder method on a dataclass, as in MyDataClass.__lt__(…)), I'm not sure it's worth it? The answer might depend on whether or not we foresee using this for purposes of resolving astral-sh/ty#491.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

I think it's fine to go with this for now.

@sharkdp sharkdp merged commit bbcd7e0 into main May 28, 2025
35 checks passed
@sharkdp sharkdp deleted the david/function-like-callables branch May 28, 2025 08:00
@AlexWaygood
Copy link
Member

given that we currently only use this for one edge case (explicit calls to a synthetic dunder method on a dataclass, as in MyDataClass.__lt__(…)), I'm not sure it's worth it?

But I think we should use function-like callables for lambdas too, since they're also instances of FunctionType at runtime (they therefore have all those attributes that you can find on instances of types.FunctionType, and can be used as methods just fine if you assign them to symbols in class namespaces()

dcreager added a commit that referenced this pull request May 28, 2025
* main:
  [ty] Support ephemeral uv virtual environments (#18335)
  Add a `ViolationMetadata::rule` method (#18234)
  Return `DiagnosticGuard` from `Checker::report_diagnostic` (#18232)
  [flake8_use_pathlib]: Replace os.symlink with Path.symlink_to (PTH211) (#18337)
  [ty] Support cancellation and retry in the server (#18273)
  [ty] Synthetic function-like callables (#18242)
  [ty] Support publishing diagnostics in the server (#18309)
  Add Autofix for ISC003 (#18256)
  [`pyupgrade`]: new rule UP050 (`useless-class-metaclass-type`) (#18334)
  [pycodestyle] Make `E712` suggestion not assume a context (#18328)
carljm added a commit to MatthewMckee4/ruff that referenced this pull request May 28, 2025
* main: (246 commits)
  [ty] Simplify signature types, use them in `CallableType` (astral-sh#18344)
  [ty] Support ephemeral uv virtual environments (astral-sh#18335)
  Add a `ViolationMetadata::rule` method (astral-sh#18234)
  Return `DiagnosticGuard` from `Checker::report_diagnostic` (astral-sh#18232)
  [flake8_use_pathlib]: Replace os.symlink with Path.symlink_to (PTH211) (astral-sh#18337)
  [ty] Support cancellation and retry in the server (astral-sh#18273)
  [ty] Synthetic function-like callables (astral-sh#18242)
  [ty] Support publishing diagnostics in the server (astral-sh#18309)
  Add Autofix for ISC003 (astral-sh#18256)
  [`pyupgrade`]: new rule UP050 (`useless-class-metaclass-type`) (astral-sh#18334)
  [pycodestyle] Make `E712` suggestion not assume a context (astral-sh#18328)
  put similar dunder-call tests next to each other (astral-sh#18343)
  [ty] Derive `PartialOrd, Ord` for `KnownInstanceType` (astral-sh#18340)
  [ty] Simplify `Type::try_bool()` (astral-sh#18342)
  [ty] Simplify `Type::normalized` slightly (astral-sh#18339)
  [ty] Move arviz off the list of selected primer projects (astral-sh#18336)
  [ty] Add --config-file CLI arg (astral-sh#18083)
  [ty] Tell the user why we inferred a certain Python version when reporting version-specific syntax errors (astral-sh#18295)
  [ty] Implement implicit inheritance from `Generic[]` for PEP-695 generic classes (astral-sh#18283)
  [ty] Add hint if async context manager is used in non-async with statement (astral-sh#18299)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants