Skip to content

[ty] Dataclass field converters#23088

Merged
sharkdp merged 8 commits intomainfrom
david/converter
Mar 25, 2026
Merged

[ty] Dataclass field converters#23088
sharkdp merged 8 commits intomainfrom
david/converter

Conversation

@sharkdp
Copy link
Copy Markdown
Contributor

@sharkdp sharkdp commented Feb 5, 2026

Summary

Adds support for dataclass field converters.

closes astral-sh/ty#972

Ecosystem impact

Lots of removed false positives on attrs, home-assistant/core, and trio.

Typing conformance results

With this PR, we pass almost all tests in dataclasses_transform_converter.py. The remaining problem in this test suite is not related to dataclasses or dataclass converters, but rather to a limitation in our generics solver (we don't understand the call to field, and therefore don't recognize the converter function).

Test Plan

New Markdown tests

@sharkdp sharkdp added ty Multi-file analysis & type inference ecosystem-analyzer labels Feb 5, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Feb 5, 2026

Typing conformance results improved 🎉

The percentage of diagnostics emitted that were expected errors increased from 85.93% to 86.29%. The percentage of expected errors that received a diagnostic increased from 80.02% to 80.11%. The number of fully passing files held steady at 66/133.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 849 850 +1 ⏫ (✅)
False Positives 139 135 -4 ⏬ (✅)
False Negatives 212 211 -1 ⏬ (✅)
Total Diagnostics 1066 1043 -23
Precision 85.93% 86.29% +0.36% ⏫ (✅)
Recall 80.02% 80.11% +0.09% ⏫ (✅)
Passing Files 66/133 66/133 +0

Test file breakdown

1 file altered
File True Positives False Positives False Negatives Status
dataclasses_transform_converter.py 9 (+1) ✅ 2 (-4) ✅ 0 (-1) ✅ 📈 Improving
Total (all files) 850 (+1) ✅ 135 (-4) ✅ 211 (-1) ✅ 66/133

True positives added (1)

1 diagnostic
Test case Diff

dataclasses_transform_converter.py:118

+error[invalid-assignment] Object of type `Literal[1]` is not assignable to attribute `field0` of type `str`

False positives removed (4)

4 diagnostics
Test case Diff

dataclasses_transform_converter.py:112

-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f0"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f2"]`
-error[invalid-argument-type] Argument is incorrect: Expected `ConverterClass`, found `Literal[b"f6"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `list[Unknown]`

dataclasses_transform_converter.py:114

-error[invalid-assignment] Object of type `Literal["f1"]` is not assignable to attribute `field0` of type `int`

dataclasses_transform_converter.py:115

-error[invalid-assignment] Object of type `Literal["f6"]` is not assignable to attribute `field3` of type `ConverterClass`

dataclasses_transform_converter.py:116

-error[invalid-assignment] Object of type `Literal[b"f6"]` is not assignable to attribute `field3` of type `ConverterClass`

True positives changed (4)

4 diagnostics
Test case Diff

dataclasses_transform_converter.py:107

-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f2"]`
-error[invalid-argument-type] Argument is incorrect: Expected `ConverterClass`, found `Literal[b"f3"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `list[Unknown]`
+error[invalid-argument-type] Argument is incorrect: Expected `str`, found `Literal[1]`

dataclasses_transform_converter.py:108

-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f0"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f2"]`
-error[invalid-argument-type] Argument is incorrect: Expected `ConverterClass`, found `Literal[1]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `list[Unknown]`
+error[invalid-argument-type] Argument is incorrect: Expected `str | bytes`, found `Literal[1]`

dataclasses_transform_converter.py:109

-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f0"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f2"]`
-error[invalid-argument-type] Argument is incorrect: Expected `ConverterClass`, found `Literal["f3"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `complex`
+error[invalid-argument-type] Argument is incorrect: Expected `str | list[str]`, found `complex`

dataclasses_transform_converter.py:119

-error[invalid-assignment] Object of type `Literal[1]` is not assignable to attribute `field3` of type `ConverterClass`
+error[invalid-assignment] Object of type `Literal[1]` is not assignable to attribute `field3` of type `str | bytes`

False positives changed (1)

1 diagnostic
Test case Diff

dataclasses_transform_converter.py:121

-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f0"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["f2"]`
-error[invalid-argument-type] Argument is incorrect: Expected `ConverterClass`, found `Literal["f6"]`
-error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["1"]`
-error[invalid-argument-type] Argument is incorrect: Expected `dict[str, str]`, found `tuple[tuple[Literal["a"], Literal["1"]], tuple[Literal["b"], Literal["2"]]]`
+error[invalid-argument-type] Argument is incorrect: Expected `dict[str, str]`, found `tuple[tuple[Literal["a"], Literal["1"]], tuple[Literal["b"], Literal["2"]]]`

@astral-sh-bot

This comment was marked as outdated.

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Feb 5, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-await 0 40 0
invalid-argument-type 0 8 0
invalid-assignment 0 2 0
invalid-return-type 0 1 0
unresolved-attribute 0 0 1
Total 0 51 1

Changes in flaky projects detected. Raw diff output excludes flaky projects; see the HTML report for details.

Raw diff:

attrs (https://github.com/python-attrs/attrs)
- tests/dataclass_transform_example.py:23:17 error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal[b"42"]`
- tests/test_hooks.py:70:15 error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["3"]`
- tests/test_next_gen.py:388:9 error[invalid-assignment] Object of type `Literal["11"]` is not assignable to attribute `x` of type `int`
- tests/test_next_gen.py:394:13 error[invalid-assignment] Object of type `Literal["9"]` is not assignable to attribute `x` of type `int`
- tests/test_next_gen.py:380:14 error[unresolved-attribute] Object of type `dataclasses.Field` has no attribute `validator`
+ tests/test_next_gen.py:380:14 error[unresolved-attribute] Object of type `dataclasses.Field[int]` has no attribute `validator`
- typing-examples/mypy.py:181:13 error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["on"]`
- typing-examples/mypy.py:182:13 error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["yes"]`
- typing-examples/mypy.py:185:13 error[invalid-argument-type] Argument is incorrect: Expected `int`, found `Literal["n"]`

core (https://github.com/home-assistant/core)
- homeassistant/helpers/entity_registry.py:1458:13 error[invalid-argument-type] Argument is incorrect: Expected `str`, found `None | str`
- homeassistant/helpers/entity_registry.py:1462:13 error[invalid-argument-type] Argument is incorrect: Expected `ReadOnlyEntityOptionsType`, found `Mapping[str, Mapping[str, Any]] | None`

trio (https://github.com/python-trio/trio)
- src/trio/_tests/test_highlevel_open_tcp_listeners.py:235:35 error[invalid-argument-type] Argument is incorrect: Expected `SocketKind`, found `int`

Full report with detailed diff (timing results)

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Feb 13, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 704.38MB 704.40MB +0.00% (22.16kB)
sphinx 262.64MB 262.64MB +0.00% (5.12kB)
trio 115.82MB 115.82MB +0.00% (912.00B)
flake8 48.13MB 48.13MB +0.00% (896.00B)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
StaticClassLiteral<'db>::fields_ 97.87kB 118.05kB +20.63% (20.19kB)
infer_scope_types_impl 53.57MB 53.57MB +0.00% (1.88kB)
FieldInstance 288.00B 384.00B +33.33% (96.00B)

sphinx

Name Old New Diff Outcome
StaticClassLiteral<'db>::fields_ 18.64kB 22.86kB +22.63% (4.22kB)
infer_scope_types_impl 15.50MB 15.50MB +0.01% (888.00B)
FieldInstance 96.00B 128.00B +33.33% (32.00B)

trio

Name Old New Diff Outcome
infer_scope_types_impl 4.76MB 4.76MB +0.02% (912.00B)

flake8

Name Old New Diff Outcome
StaticClassLiteral<'db>::fields_ 4.11kB 4.98kB +21.31% (896.00B)

@sharkdp sharkdp force-pushed the david/converter branch 2 times, most recently from 547d926 to 5c43bb1 Compare February 27, 2026 13:20
Comment thread crates/ty_python_semantic/src/types/infer/builder.rs Outdated
Copy link
Copy Markdown
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.

Nice!

Comment thread crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md Outdated
Comment thread crates/ty_python_semantic/src/types/class.rs Outdated
}

if let Some(converter_ty) = converter_input_type {
field_ty = converter_ty;
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.

I think this method is currently also used for synthesizing __replace__ signature, but I think __replace__ should always use the raw field type, not the converter input type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think __replace__ should always use the raw field type, not the converter input type.

Hm, that would depend on the actual __replace__ implementation, no? I only know one library which makes use of converter (attrs), and it seems that they do call the converter function when using replace(…):

I also verified this behavior locally with a small example.

I added a test to reflect and describe the current behavior.

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.

Good call! FWIW it looks like pyrefly and pyright also agree with the behavior you implemented here; mypy does not. (But I guess mypy has an attrs plugin, so probably doesn't care.)

Comment thread crates/ty_python_semantic/src/types/infer/builder.rs Outdated
Comment on lines +1145 to +1147
let mut input_types = UnionBuilder::new(db);
let mut found_any = false;
for binding in converter_ty.bindings(db).iter_flat() {
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.

The use of iter_flat means that we collapse the union/intersection structure of converter_ty.

If it is an intersection of callables, I think unioning the discovered first-parameter types is actually correct (an intersection of callables is similar to overloads, in that the callable accepts all of those types.)

But if converter_ty is a union of callables, then I think technically we should build an intersection of their first parameter types? If the converter either accepts A or B, but we don't know which, then only A & B is safe to provide to the field.

(Totally open to saying we don't need to care about this, but it's at least worth a comment, I think. Pyright and mypy seem to just fail in this union-of-callables case and ignore the converter entirely; pyrefly does the same as this PR and uses the union of the first-argument types.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I decided to only add a comment why this is currently not supported.

But this raises an interesting question: should a union of Callables be assignable to a Callable[[S], T] where S and T are type variables? We currently don't allow this:

from typing import Callable

def identity[S, T](c: Callable[[S], T]) -> Callable[[S], T]:
    return c

class A: ...
class B: ...

def _(c: Callable[[A], A] | Callable[[B], B]):
    reveal_type(identity(c))  # should this be Callable[[A & B], A & B]?

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.

This is a fun one to run through multiplay :) One of those "every type checker has a different answer" cases. Won't summarize, but gist 252ff444d987ffdc24bfa79f13909615 if you're curious.

I think the comment here is fine.

@sharkdp sharkdp marked this pull request as draft March 10, 2026 13:55
@sharkdp sharkdp force-pushed the david/converter branch 2 times, most recently from 1053d14 to c4a9297 Compare March 10, 2026 14:19
@rolfmorel
Copy link
Copy Markdown

As there's progress here, I thought to cross-post astral-sh/ty#1327 (comment):

@sharkdp, great that you're working on this!

Regarding getting converter support landed, it would be great to know where you/ty stand regarding the following question:

python/typing#2189: Does converter also support default argument in field specifiers of dataclass_transform?

CC: @PragmaTwice

@AlexWaygood AlexWaygood removed their request for review March 25, 2026 09:41
@sharkdp
Copy link
Copy Markdown
Contributor Author

sharkdp commented Mar 25, 2026

There were some additional complications here regarding generic converter functions that showed up in the ecosystem. No other type checker can handle this situation either, and so I opted for a simple strategy for now to avoid false positives (default-specialize the callable to make it assignable). Once our generic solver can properly handle passing generic callables to generic callables, we can revisit this, but it seems relatively complex, and at the same time not very important (but I added a TODO).

@sharkdp sharkdp marked this pull request as ready for review March 25, 2026 11:44
@astral-sh-bot astral-sh-bot Bot requested a review from carljm March 25, 2026 11:44
@sharkdp sharkdp merged commit 9b23d4b into main Mar 25, 2026
49 checks passed
@sharkdp sharkdp deleted the david/converter branch March 25, 2026 11:51
carljm added a commit that referenced this pull request Mar 25, 2026
* main:
  [ty] make `test-case` a dev-dependency (#24187)
  [ty] implement cycle normalization for more types to prevent too-many-cycle panics (#24061)
  [ty] Silence all diagnostics in unreachable code (#24179)
  [ty] Intern `InferableTypeVars` (#24161)
  Implement unnecessary-if (RUF050) (#24114)
  Recognize `Self` annotation and `self` assignment in SLF001 (#24144)
  Bump the npm version before publish (#24178)
  [ty] Disallow Self in metaclass and static methods (#23231)
  Use trusted publishing for NPM packages (#24171)
  [ty] Respect non-explicitly defined dataclass params (#24170)
  Add RUF072: warn when using  operator on an f-string (#24162)
  [ty] Check return type of generator functions (#24026)
  Implement useless-finally (RUF-072) (#24165)
  [ty] Add test for a dataclass with a default field converter (#24169)
  [ty] Dataclass field converters (#23088)
  [flake8-bandit] Treat sys.executable as trusted input in S603 (#24106)
  [ty] Add support for `typing.Concatenate` (#23689)
  `ASYNC115`: autofix to use full qualified `anyio.lowlevel` import (#24166)
  [ty] Disallow read-only fields in TypedDict updates (#24128)
  Speed up diagnostic rendering (#24146)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for dataclass_transform converters

3 participants