Skip to content

[ty] Respect property deleters in attribute deletion checks#24500

Merged
charliermarsh merged 4 commits intomainfrom
charlie/deleter-i
Apr 10, 2026
Merged

[ty] Respect property deleters in attribute deletion checks#24500
charliermarsh merged 4 commits intomainfrom
charlie/deleter-i

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

Summary

This PR adds support for deleter properties, so we now error on the following:

class ReadOnlyProperty:
    @property
    def x(self) -> int:
        return 1

read_only = ReadOnlyProperty()
# error: [invalid-assignment] "Cannot delete read-only property `x` on object of type `ReadOnlyProperty`"
del read_only.x

While this looks like a lot of code, it ends up being a fairly mechanical change -- almost every line corresponds to a similar line for setters and/or getters, with the exception of validate_attribute_deletion.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Apr 8, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

Typing conformance results improved 🎉

The percentage of diagnostics emitted that were expected errors increased from 87.79% to 87.80%. The percentage of expected errors that received a diagnostic increased from 82.81% to 82.91%. The number of fully passing files improved from 75/132 to 76/132.

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 877 878 +1 ⏫ (✅)
False Positives 122 122 +0
False Negatives 182 181 -1 ⏬ (✅)
Total Diagnostics 1049 1050 +1
Precision 87.79% 87.80% +0.01% ⏫ (✅)
Recall 82.81% 82.91% +0.09% ⏫ (✅)
Passing Files 75/132 76/132 +1 ⏫ (✅)

Test file breakdown

1 file altered
File True Positives False Positives False Negatives Status
namedtuples_usage.py 8 (+1) ✅ 0 0 (-1) ✅ ✅ Newly Passing 🎉
Total (all files) 878 (+1) ✅ 122 181 (-1) ✅ 76/132

True positives added (1)

1 diagnostic
Test case Diff

namedtuples_usage.py:42

+error[invalid-assignment] Cannot delete read-only property `x` on object of type `Point`

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 708.55MB 708.56MB +0.00% (17.13kB)
sphinx 261.98MB 261.99MB +0.00% (7.66kB)
trio 117.42MB 117.43MB +0.00% (4.20kB)
flake8 47.82MB 47.83MB +0.00% (528.00B)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
PropertyInstanceType 40.73kB 48.34kB +18.68% (7.61kB)
Type<'db>::class_member_with_policy_ 17.42MB 17.42MB +0.02% (2.75kB)
Type<'db>::member_lookup_with_policy_::interned_arguments 5.89MB 5.89MB +0.02% (936.00B)
infer_scope_types_impl 54.28MB 54.28MB +0.00% (924.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 9.94MB 9.94MB +0.01% (896.00B)
Type<'db>::member_lookup_with_policy_ 16.13MB 16.13MB +0.01% (852.00B)
FunctionType<'db>::last_definition_signature_ 797.39kB 798.22kB +0.10% (848.00B)
Type<'db>::class_member_with_policy_::interned_arguments 9.68MB 9.68MB +0.01% (832.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 5.32MB 5.32MB +0.01% (768.00B)
FunctionType 8.78MB 8.78MB +0.01% (736.00B)
Type<'db>::apply_specialization_ 3.68MB 3.68MB +0.00% (96.00B)
StaticClassLiteral<'db>::variance_of_ 205.18kB 205.21kB +0.01% (24.00B)
infer_definition_types 89.91MB 89.91MB +0.00% (24.00B)

sphinx

Name Old New Diff Outcome
PropertyInstanceType 29.39kB 34.84kB +18.53% (5.45kB)
Type<'db>::class_member_with_policy_ 7.64MB 7.64MB +0.01% (732.00B)
Type<'db>::class_member_with_policy_::interned_arguments 4.03MB 4.03MB +0.01% (312.00B)
Type<'db>::member_lookup_with_policy_::interned_arguments 2.67MB 2.67MB +0.01% (312.00B)
Type<'db>::member_lookup_with_policy_ 6.25MB 6.25MB +0.00% (264.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 2.36MB 2.36MB +0.01% (224.00B)
infer_scope_types_impl 15.46MB 15.46MB +0.00% (216.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 1.93MB 1.93MB +0.01% (192.00B)
infer_definition_types 23.76MB 23.76MB +0.00% (12.00B)

trio

Name Old New Diff Outcome
PropertyInstanceType 13.49kB 15.95kB +18.18% (2.45kB)
Type<'db>::class_member_with_policy_ 2.05MB 2.05MB +0.02% (428.00B)
infer_scope_types_impl 4.77MB 4.77MB +0.01% (312.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 745.57kB 745.79kB +0.03% (224.00B)
Type<'db>::member_lookup_with_policy_ 1.80MB 1.80MB +0.01% (212.00B)
Type<'db>::class_member_with_policy_::interned_arguments 1.13MB 1.13MB +0.02% (208.00B)
Type<'db>::member_lookup_with_policy_::interned_arguments 925.34kB 925.54kB +0.02% (208.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 602.16kB 602.34kB +0.03% (192.00B)

flake8

Name Old New Diff Outcome
PropertyInstanceType 2.84kB 3.35kB +18.18% (528.00B)

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 8, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-assignment 1 0 0
Total 1 0 0

Raw diff:

pytest (https://github.com/pytest-dev/pytest)
+ src/_pytest/unittest.py:493:21 error[invalid-assignment] Cannot delete read-only property `result` on object of type `CallInfo[None]`: Attempted deletion of `CallInfo[None].result` here

Full report with detailed diff (timing results)

Base automatically changed from charlie/none-property to main April 8, 2026 18:49
@charliermarsh charliermarsh force-pushed the charlie/deleter-i branch 2 times, most recently from 8086d7c to e012b55 Compare April 8, 2026 19:44
@charliermarsh charliermarsh marked this pull request as ready for review April 9, 2026 00:28
@astral-sh-bot astral-sh-bot Bot requested a review from oconnor663 April 9, 2026 00:28
@carljm carljm removed their request for review April 9, 2026 01:02
Copy link
Copy Markdown
Contributor

@oconnor663 oconnor663 left a comment

Choose a reason for hiding this comment

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

LGTM with mostly some style nits and questions I'm not sure about. The one thing that looks like it might need a more substantial changes is del.md:215, if you meant for that case to fail?

x: int = 1

# error: [invalid-assignment] "Cannot delete attribute `x` on type `<class 'RejectsClassDelete'>` whose `__delattr__` method returns `Never`/`NoReturn`"
del RejectsClassDelete.x
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.

Worth including a SupportsClassDelete case in this section that tests def __delattr__(...) -> None on the metaclass?

Comment on lines +2733 to +2735
if !self.validate_attribute_deletion(target, *element_ty, attribute, false) {
if emit_diagnostics {
self.validate_attribute_deletion(target, *element_ty, attribute, true);
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.

What's the reason for doing it this way instead of passing emit_diagnostics as an argument to a single call? I don't notice any test failures when I do

diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 3c426815bb..3c4958cef2 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -2730,10 +2730,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
         match object_ty {
             Type::Union(union) => {
                 for element_ty in union.elements(db) {
-                    if !self.validate_attribute_deletion(target, *element_ty, attribute, false) {
-                        if emit_diagnostics {
-                            self.validate_attribute_deletion(target, *element_ty, attribute, true);
-                        }
+                    if !self.validate_attribute_deletion(
+                        target,
+                        *element_ty,
+                        attribute,
+                        emit_diagnostics,
+                    ) {
                         return false;
                     }
                 }

} else {
if emit_diagnostics && let Some(element_ty) = intersection.positive(db).first()
{
self.validate_attribute_deletion(target, *element_ty, attribute, true);
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 one makes more sense to me though, compared to the union one above. The deletion here needs to be valid on any intersection member, so we can't emit diagnostics until we know that all of them fail to validate. 👍

My question here is, is intersection.positive(db).first() always going to give us the same element? positive is an FxOrderSet, so if it's always constructed in the same order then I think the first element will always be the same, but I don't know if there are cases where "something something multiple files multiple threads" could make the construction order nondeterministic. If so, could this maybe produce flaky test cases?

In retrospect I guess I have the same question about union.elements above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think it should be okay. It's an ordered set, and union.elements is a boxed slice.

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.

Yeah it's clear to me that iterating over those containers is deterministic. What I'm not entirely sure about is whether we can rely on those containers to always be constructed in the same order from one run to the next.

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.

If we can't, then the blame for the non-determinism would fall on whatever is causing them to be constructed in different orders. The fact that the sets are ordered is sufficient to make this code OK.

alias.value_type(db),
attribute,
emit_diagnostics,
),
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 is its own arm because the alias could be a union or intersection underneath, and we want to hit those arms above if so? If that's right, do we need Type::NewTypeInstance to similarly have its own arm? In the "NewType of float or complex" cases, it's also a union. On the other hand, maybe we know that there are no relevant deleters in that case? Probably worth a comment either way.

Comment on lines +2795 to +2823
match &delattr_dunder_call_result {
Ok(result) if result.return_type(db).is_never() => {
if emit_diagnostics
&& let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` \
whose `__delattr__` method returns `Never`/`NoReturn`",
object_ty.display(db),
));
}
return false;
}
Err(err) if err.return_type(db).is_some_and(|ty| ty.is_never()) => {
if emit_diagnostics
&& let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` \
whose `__delattr__` method returns `Never`/`NoReturn`",
object_ty.display(db),
));
}
return false;
}
_ => {}
}
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.

Suggested change
match &delattr_dunder_call_result {
Ok(result) if result.return_type(db).is_never() => {
if emit_diagnostics
&& let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` \
whose `__delattr__` method returns `Never`/`NoReturn`",
object_ty.display(db),
));
}
return false;
}
Err(err) if err.return_type(db).is_some_and(|ty| ty.is_never()) => {
if emit_diagnostics
&& let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` \
whose `__delattr__` method returns `Never`/`NoReturn`",
object_ty.display(db),
));
}
return false;
}
_ => {}
}
let returns_never = match &delattr_dunder_call_result {
Ok(result) => result.return_type(db).is_never(),
Err(err) => err.return_type(db).is_some_and(|ty| ty.is_never()),
};
if returns_never {
if emit_diagnostics
&& let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` \
whose `__delattr__` method returns `Never`/`NoReturn`",
object_ty.display(db),
));
}
return false;
}

object_type.display(db)
));
}
}
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 diagnostic is great; probably wants a snapshot test?

del supports_delete.x

rejects_descriptor_delete = RejectsDescriptorDelete()
del rejects_descriptor_delete.x
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.

It looks like this one was intended to be an error but currently isn't for some reason?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gah, sorry -- this needs a TODO, it's fixed in #24510. (Originally it was part of this PR, then I broke it out.)

};
let db = context.db();
let mut diagnostic = builder.into_diagnostic(format_args!(
"Cannot delete attribute `{attribute}` on type `{}` with custom `__delattr__` method.",
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.

Suggested change
"Cannot delete attribute `{attribute}` on type `{}` with custom `__delattr__` method.",
"Cannot delete attribute `{attribute}` on type `{}` with custom `__delattr__` method",

Copy link
Copy Markdown
Contributor

@oconnor663 oconnor663 left a comment

Choose a reason for hiding this comment

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

LGTM after this TODO is added.

@charliermarsh charliermarsh merged commit 90fd7dd into main Apr 10, 2026
55 checks passed
@charliermarsh charliermarsh deleted the charlie/deleter-i branch April 10, 2026 21:33
carljm added a commit that referenced this pull request Apr 10, 2026
* main:
  Bump typing conformance suite commit to latest upstream (#24553)
  [ty] Reject deleting`Final` attributes (#24508)
  [ty] Respect property deleters in attribute deletion checks (#24500)
  [ty] stop unioning Unknown into types of un-annotated attributes (#24531)
  [ty] Fix bad diagnostic range for incorrect implicit `__init_subclass__` calls (#24541)
  [ty] Add a `SupportedPythonVersion` enum (#24412)
  [ty] Ignore unsupported editor-selected Python versions (#24498)
  [ty] Add snapshots for `__init_subclass__` diagnostics (#24539)
  [ty] Minor fix in tests (#24538)
  [ty] Allow `Final` variable assignments in `__post_init__` (#24529)
  [ty] Expand test suite for assignment errors (#24537)
  [ty] Use `map`, not `__map`, as the name of the mapping parameter in `TypedDict` `__init__` methods (#24535)
  [ty] Rework logic for synthesizing `TypedDict` methods (#24534)
  [flake8-bandit] Fix S103 false positives and negatives in mask analysis (#24424)
  [ty] mdtest.py: update dependencies (#24533)
  Rename patterns and arguments source order iterator method (#24532)
  [ty] Omit invalid keyword arguments from `TypedDict` signature (#24522)
  [ty] support super() in metaclass methods (#24483)
  [ty] Synthesize `__init__` for `TypedDict` (#24476)
c = C()
del c.attr # error: [invalid-assignment]
```

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.

Wait, why did this test end up here? 😄 It has nothing to do with the topic of this test suite.

#24691

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.

4 participants