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` |