From 6a5ad3769dd50e3102f57d43c82787a44a1fd09c Mon Sep 17 00:00:00 2001 From: Conner Nilsen Date: Wed, 7 Oct 2026 09:26:13 -0700 Subject: [PATCH] Add `unknown-member-type` for accessing attributes of unknown type (#5108) Summary: Pull Request resolved: https://github.com/facebook/pyrefly/pull/5108 Pyright's `reportUnknownMemberType` reports member accesses whose type is unknown. Pyrefly's migration mapped it to `unknown-attribute-type`, but that kind only fires where an unannotated attribute is declared with an unknown value, so accessing a member of unknown type was never reported. This adds an `unknown-member-type` error kind, reported for every attribute read whose result is an implicit `Any`. Attribute reads all go through `attr_access_infer`, both plain accesses and method callees, so the check lives there; assignment targets are bound elsewhere and are not affected. Like pyright, accesses on a base that is itself unknown are reported too, so `obj.a.b` on an unannotated parameter reports both `a` and `b`. Explicit `Any` is treated as intentional, missing attributes keep reporting `missing-attribute` (they produce an error `Any`), and partially unknown types such as `list[Any]` are left to #5062. It is a separate kind rather than an extension of `unknown-attribute-type` so that the much noisier access-site reports can be enabled independently. Pyright's single rule covers both declarations and accesses, so the migration now maps `reportUnknownMemberType` to both kinds. ## mypy_primer results mypy_primer reports 353,232 new errors across 146 projects, all `unknown-member-type`, and 176 fewer `unused-type-ignore` errors, from existing `# type: ignore` comments that now suppress one of the new errors. The per-preset breakdown is +0/-0 for both `default` and `strict`: only `--preset=all`, which primer uses, enables this kind. The number is high because the kind reports every attribute access whose type is unknown, and the projects are large and mostly unannotated: 40% of the new errors come from five of them (sympy, pandas, ibis, spark and scikit-learn). No single pattern dominates: the errors involve 21,929 distinct attribute names, and the 25 most common cover 17.6% of them. Reporting each level of a chain is a small part of the total: only 11.7% are accesses chained onto another reported access on the same line. Most are the first unknown access on a line, typically on an unannotated parameter or on a value returned by an unannotated function. The new errors match pyright. Running Pyrefly on PyPI installs of sympy, pandas, ibis, pyspark, scikit-learn, twisted, jax and streamlit, checked as projects like primer does, gives the same counts as primer where the package contains the same code (sympy 40,483 versus 40,268, ibis 23,631 versus 23,668, scikit-learn 21,473 versus 21,387). In a random sample of 13,012 of those errors from 300 files, basedpyright reports the same member access for 92.8% of them (85.2% as unknown and 7.6% as partially unknown). Of the other 7.2%, 41% are attributes assigned directly from an unannotated `__init__` parameter, which pyright does not report although their type is unknown. The rest are cases where pyright infers a more precise type than Pyrefly, mostly through its call-site return type inference for unannotated functions. The test suites of spark and streamlit, which account for some of primer's errors in those projects, are not included in their PyPI packages and were not sampled. Projects that import pandas, scikit-learn, sympy, scikit-image, vispy, conans, boto3 or botocore can see more of these errors, because Pyrefly resolves those libraries to its bundled stubs, and many declarations in some of those stubs are unannotated. That is a property of the bundled stubs, not of this check. Fixes https://github.com/facebook/pyrefly/issues/5061 Reviewed By: rchen152 Differential Revision: D122886716 --- crates/pyrefly_config/src/config.rs | 4 +++ crates/pyrefly_config/src/error_kind.rs | 3 ++ .../src/migration/error_codes.rs | 6 +++- .../pyrefly_config/src/migration/pyright.rs | 4 +++ pyrefly/lib/alt/expr.rs | 17 ++++++++- pyrefly/lib/test/attributes.rs | 35 +++++++++++++++++++ pyrefly/lib/test/util.rs | 10 ++++++ scripts/error_presets.json | 1 + website/docs/error-kinds.mdx | 21 +++++++++++ .../migrate/pyright/diagnostics-reference.mdx | 2 +- .../migrate/pyright/unknown-member-type.mdx | 24 ++++++++----- 11 files changed, 116 insertions(+), 11 deletions(-) diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index 7a74460a23..96836809d4 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -3616,6 +3616,10 @@ output-format = "omit-errors" errors.severity(ErrorKind::UnknownAttributeType), Severity::Ignore ); + assert_eq!( + errors.severity(ErrorKind::UnknownAttributeAccess), + Severity::Ignore + ); assert_eq!( errors.severity(ErrorKind::UnknownVariableType), Severity::Ignore diff --git a/crates/pyrefly_config/src/error_kind.rs b/crates/pyrefly_config/src/error_kind.rs index acd604efb0..e52f818c65 100644 --- a/crates/pyrefly_config/src/error_kind.rs +++ b/crates/pyrefly_config/src/error_kind.rs @@ -427,6 +427,8 @@ pub enum ErrorKind { /// A call argument whose type is an implicit `Any` (unknown), because the value /// passed has an unknown type. UnknownArgumentType, + /// Accessing an attribute whose type is an implicit `Any` (unknown). + UnknownAttributeAccess, /// An unannotated attribute assigned a value with unknown type. UnknownAttributeType, /// Accessing a DataFrame column that does not exist in the inferred schema. @@ -622,6 +624,7 @@ impl ErrorKind { ErrorKind::UnknownArgumentType => Severity::Ignore, ErrorKind::ImplicitAnyLambda => Severity::Ignore, ErrorKind::UnknownAttributeType => Severity::Ignore, + ErrorKind::UnknownAttributeAccess => Severity::Ignore, ErrorKind::UnknownVariableType => Severity::Ignore, ErrorKind::UnnecessaryComparison => Severity::Warn, ErrorKind::UnnecessaryTypeConversion => Severity::Warn, diff --git a/crates/pyrefly_config/src/migration/error_codes.rs b/crates/pyrefly_config/src/migration/error_codes.rs index ee1d66f860..8fd0cf1c88 100644 --- a/crates/pyrefly_config/src/migration/error_codes.rs +++ b/crates/pyrefly_config/src/migration/error_codes.rs @@ -310,11 +310,15 @@ mod tests { assert!(result.is_ok()); let errors = pyrefly_cfg.root.errors.as_ref().unwrap(); - // `reportUnknownMemberType` maps to `unknown-attribute-type`. + // `reportUnknownMemberType` covers both declaring and accessing a member of unknown type. assert_eq!( errors.severity(ErrorKind::UnknownAttributeType), Severity::Error ); + assert_eq!( + errors.severity(ErrorKind::UnknownAttributeAccess), + Severity::Error + ); } #[test] diff --git a/crates/pyrefly_config/src/migration/pyright.rs b/crates/pyrefly_config/src/migration/pyright.rs index 0061e65b00..1553ff76c4 100644 --- a/crates/pyrefly_config/src/migration/pyright.rs +++ b/crates/pyrefly_config/src/migration/pyright.rs @@ -456,6 +456,10 @@ impl RuleOverrides { self.report_unknown_member_type, ErrorKind::UnknownAttributeType, ); + add( + self.report_unknown_member_type, + ErrorKind::UnknownAttributeAccess, + ); add( self.report_unknown_lambda_type, ErrorKind::ImplicitAnyLambda, diff --git a/pyrefly/lib/alt/expr.rs b/pyrefly/lib/alt/expr.rs index 6a0cc80e33..5e7cafdb4f 100644 --- a/pyrefly/lib/alt/expr.rs +++ b/pyrefly/lib/alt/expr.rs @@ -2492,7 +2492,22 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { x.attr.range, AttributeReferenceKind::Textual, ); - self.attr_infer(base, &x.attr.id, x.range, errors, None) + let result = self.attr_infer(base, &x.attr.id, x.range, errors, None); + // Like pyright, every access whose type is unknown is reported, including accesses on a + // base that is itself unknown. Missing attributes produce an error `Any` instead, which + // is reported as `missing-attribute`. + if matches!(result.ty(), Type::Any(AnyStyle::Implicit)) { + self.error( + errors, + x.attr.range, + ErrorKind::UnknownAttributeAccess, + format!( + "The type of attribute `{}` is unknown; it is inferred as an implicit `Any`", + x.attr.id + ), + ); + } + result } pub fn attr_infer( diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index 72bbc3ef57..e0e2ca4dea 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -3404,3 +3404,38 @@ def use(c: C) -> None: reveal_type(c.f) # E: revealed type: Unknown "#, ); + +testcase!( + test_unknown_attribute_access, + TestEnv::new().enable_unknown_attribute_access_error(), + r#" +class C: + def __init__(self, v) -> None: + self.known: int = 1 + self.unknown = v + +def use(obj, c: C) -> None: + obj.attr # E: The type of attribute `attr` is unknown + obj.method(1) # E: The type of attribute `method` is unknown + obj.attr.deeper # E: The type of attribute `attr` is unknown # E: The type of attribute `deeper` is unknown + c.unknown # E: The type of attribute `unknown` is unknown + c.known +"#, +); + +testcase!( + test_unknown_attribute_access_no_error, + TestEnv::new().enable_unknown_attribute_access_error(), + r#" +from typing import Any + +class C: + x: int = 1 + +def use(obj, a: Any, c: C) -> None: + a.attr # An explicit `Any` is intentional. + c.missing # E: Object of class `C` has no attribute `missing` + obj.attr = 1 # Assignment targets are not accesses. + c.x +"#, +); diff --git a/pyrefly/lib/test/util.rs b/pyrefly/lib/test/util.rs index e36431faee..147468eac0 100644 --- a/pyrefly/lib/test/util.rs +++ b/pyrefly/lib/test/util.rs @@ -186,6 +186,7 @@ pub struct TestEnv { empty_body_error: bool, unknown_argument_type_error: bool, unknown_variable_type_error: bool, + unknown_attribute_access_error: bool, implicit_reexport_error: bool, potential_bad_keyword_argument_error: bool, default_require_level: Require, @@ -246,6 +247,7 @@ impl TestEnv { empty_body_error: false, unknown_argument_type_error: false, unknown_variable_type_error: false, + unknown_attribute_access_error: false, implicit_reexport_error: false, potential_bad_keyword_argument_error: false, default_require_level: Require::Exports, @@ -534,6 +536,11 @@ impl TestEnv { self } + pub fn enable_unknown_attribute_access_error(mut self) -> Self { + self.unknown_attribute_access_error = true; + self + } + pub fn enable_implicit_reexport_error(mut self) -> Self { self.implicit_reexport_error = true; self @@ -760,6 +767,9 @@ impl TestEnv { if self.unknown_variable_type_error { errors.set_error_severity(ErrorKind::UnknownVariableType, Severity::Error); } + if self.unknown_attribute_access_error { + errors.set_error_severity(ErrorKind::UnknownAttributeAccess, Severity::Error); + } config.extra_file_extensions = self.extra_file_extensions.clone(); let mut sourcedb = MapDatabase::new(config.get_sys_info()); for (name, path, _) in self.modules.iter() { diff --git a/scripts/error_presets.json b/scripts/error_presets.json index 23ab8ea9e9..a176136342 100644 --- a/scripts/error_presets.json +++ b/scripts/error_presets.json @@ -129,6 +129,7 @@ "unimported-directive": ["legacy", "default", "strict", "all"], "uninitialized-instance-variable": ["all"], "unknown-argument-type": ["all"], + "unknown-attribute-access": ["all"], "unknown-attribute-type": ["all"], "unknown-column": ["legacy", "default", "strict", "all"], "unknown-name": ["basic", "legacy", "default", "strict", "all"], diff --git a/website/docs/error-kinds.mdx b/website/docs/error-kinds.mdx index e343cd4f2b..d9dcac71a2 100644 --- a/website/docs/error-kinds.mdx +++ b/website/docs/error-kinds.mdx @@ -2048,6 +2048,27 @@ def f(n: int) -> None: ... f(untyped(1)) # the argument's type is unknown [unknown-argument-type] ``` +## unknown-attribute-access + +Default severity: `ignore` + +An attribute is accessed whose type is an implicit `Any` (unknown), so uses of the result are not checked. This mirrors pyright's `reportUnknownMemberType` for member accesses. Every such access is reported, including accesses on a value that is itself unknown, so a chain like `obj.a.b` on an unannotated parameter reports both `a` and `b`. Accesses on an explicit `Any` are treated as intentional and are not reported. + +Compare this with [unknown-attribute-type](#unknown-attribute-type), which is reported where an unannotated attribute is assigned a value of unknown type. + +```python +def untyped(x): # unannotated: returns an implicit `Any` + return x + +class C: + def __init__(self) -> None: + self.value = untyped(1) + +def use(c: C, obj) -> None: + c.value # the type of attribute `value` is unknown [unknown-attribute-access] + obj.attr.deeper # both `attr` and `deeper` are unknown [unknown-attribute-access] +``` + ## unknown-attribute-type Default severity: `ignore` diff --git a/website/docs/migrate/pyright/diagnostics-reference.mdx b/website/docs/migrate/pyright/diagnostics-reference.mdx index 366dad1165..20588d85d8 100644 --- a/website/docs/migrate/pyright/diagnostics-reference.mdx +++ b/website/docs/migrate/pyright/diagnostics-reference.mdx @@ -109,7 +109,7 @@ several kinds. | `reportUnknownArgumentType` | `unknown-argument-type` | Automatic | Direct | | | `reportUnknownLambdaType` | `implicit-any-lambda` | Automatic | Direct-ish | | | `reportUnknownVariableType` | `unknown-variable-type` | Automatic | Direct | | -| `reportUnknownMemberType` | `unknown-attribute-type` | Automatic | Direct-ish | | +| `reportUnknownMemberType` | `unknown-attribute-access`, `unknown-attribute-type` | Automatic | Direct-ish | | | `reportMissingParameterType` | `implicit-any-parameter` | Automatic | Direct-ish | | | `reportMissingTypeArgument` | `implicit-any-type-argument` | Automatic | Direct-ish | | | `reportInvalidTypeVarUse` | `invalid-type-var` | Automatic | Broad | Some variance-specific cases use `invalid-variance` or `variance-mismatch`. | diff --git a/website/docs/migrate/pyright/unknown-member-type.mdx b/website/docs/migrate/pyright/unknown-member-type.mdx index a6f5b4f508..b17d323af3 100644 --- a/website/docs/migrate/pyright/unknown-member-type.mdx +++ b/website/docs/migrate/pyright/unknown-member-type.mdx @@ -2,13 +2,15 @@ title: Pyright reportUnknownMemberType in Pyrefly slug: /migrate/pyright/unknown-member-type description: - Map Pyright reportUnknownMemberType to Pyrefly unknown-attribute-type and - distinguish unknown member types from missing attributes. + Map Pyright reportUnknownMemberType to Pyrefly unknown-attribute-access and + unknown-attribute-type, and distinguish unknown member types from missing + attributes. keywords: - reportUnknownMemberType - pyright unknown member type - pyright unknown attribute - unknown-attribute-type + - unknown-attribute-access --- {/* @@ -19,16 +21,21 @@ keywords: */} Pyright's `reportUnknownMemberType` fires when a member exists but its type -cannot be determined. `pyrefly init` maps it to -[`unknown-attribute-type`](../../error-kinds.mdx#unknown-attribute-type): +cannot be determined, both where the member is accessed and where it is +assigned. Pyrefly splits these into two kinds, and `pyrefly init` maps the rule +to both: +[`unknown-attribute-access`](../../error-kinds.mdx#unknown-attribute-access) for accesses +and [`unknown-attribute-type`](../../error-kinds.mdx#unknown-attribute-type) for +assignments of an unknown value to an unannotated attribute. ```toml [errors] +unknown-attribute-access = "error" unknown-attribute-type = "error" ``` -The Pyrefly kind defaults to `ignore`, so a project that relied on Pyright's -strict unknown-member policy has to enable it explicitly. `pyrefly init` does +Both kinds default to `ignore`, so a project that relied on Pyright's strict +unknown-member policy has to enable them explicitly. `pyrefly init` does that for you when the Pyright config sets the rule. ## Unknown is not the same as missing @@ -46,7 +53,8 @@ Pyrefly reports these as different kinds: | Scenario | Pyrefly kind | | --------------------------------------------------------------------------------------------------- | -------------------------- | -| The attribute exists but its inferred type is unknown | `unknown-attribute-type` | +| An attribute whose type is unknown is accessed | `unknown-attribute-access` | +| An unannotated attribute is assigned a value of unknown type | `unknown-attribute-type` | | The attribute does not exist | `missing-attribute` | | An imported name is absent from a module | `missing-module-attribute` | | The attribute is implicitly `Any`-like because it was assigned `None` or `()` without an annotation | `implicit-any-attribute` | @@ -58,7 +66,7 @@ policy rather than preserving it. | Pyright | Pyrefly | | ---------------------------- | ------------------------ | -| `reportUnknownMemberType` | `unknown-attribute-type` | +| `reportUnknownMemberType` | `unknown-attribute-access`, `unknown-attribute-type` | | `reportUnknownVariableType` | `unknown-variable-type` | | `reportUnknownArgumentType` | `unknown-argument-type` | | `reportUnknownParameterType` | `implicit-any-parameter` |