Skip to content

fix new rule - ban dynamic types #3765#3869

Draft
asukaminato0721 wants to merge 1 commit into
facebook:mainfrom
asukaminato0721:3765
Draft

fix new rule - ban dynamic types #3765#3869
asukaminato0721 wants to merge 1 commit into
facebook:mainfrom
asukaminato0721:3765

Conversation

@asukaminato0721

Copy link
Copy Markdown
Contributor

Summary

Fixes #3765

Added unsupported-dynamic-base error kind, added builtin type(name, bases, dict) checking, and documented the new error kind.

Test Plan

add test

@github-actions

Copy link
Copy Markdown

Diff from mypy_primer, showing the effect of this PR on open source code:

mkdocs (https://github.com/mkdocs/mkdocs)
+ ERROR mkdocs/config/config_options.py:101:28-31: Base class `type[Self@SubConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR mkdocs/plugins.py:75:28-31: Base class `type[Self@BasePlugin]` in `type()` call is not a statically known class [unsupported-dynamic-base]

steam.py (https://github.com/Gobot1234/steam.py)
+ ERROR steam/_gc/client.py:74:32-46: Base classes in `type()` calls must be a tuple literal of statically known classes [unsupported-dynamic-base]
+ ERROR steam/enums.py:120:13-122:14: Base classes in `type()` calls must be a tuple literal of statically known classes [unsupported-dynamic-base]

spark (https://github.com/apache/spark)
+ ERROR python/pyspark/pandas/typedef/typehints.py:881:43-55: Base class `type[IndexNameTypeHolder | NameTypeHolder]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR python/pyspark/pandas/typedef/typehints.py:901:43-55: Base class `type[IndexNameTypeHolder | NameTypeHolder]` in `type()` call is not a statically known class [unsupported-dynamic-base]

scikit-build-core (https://github.com/scikit-build/scikit-build-core)
+ ERROR src/scikit_build_core/setuptools/wrapper.py:71:14-23: Base class `type[_DistributionT]` in `type()` call is not a statically known class [unsupported-dynamic-base]

bidict (https://github.com/jab/bidict)
+ ERROR bidict/_base.py:148:47-50: Base class `type[Self@BidictBase]` in `type()` call is not a statically known class [unsupported-dynamic-base]

psycopg (https://github.com/psycopg/psycopg)
+ ERROR psycopg/psycopg/types/array.py:339:52-56: Base class `type[ArrayLoader] | Any` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR psycopg/psycopg/types/json.py:120:26-30: Base class `type[Dumper]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR psycopg/psycopg/types/json.py:140:26-30: Base class `type[Loader]` in `type()` call is not a statically known class [unsupported-dynamic-base]

schemathesis (https://github.com/schemathesis/schemathesis)
+ ERROR src/schemathesis/core/deserialization.py:137:54-64: Base class `type[CSafeLoader] | type[SafeLoader]` in `type()` call is not a statically known class [unsupported-dynamic-base]

beartype (https://github.com/beartype/beartype)
+ ERROR beartype/_util/cls/utilclsmake.py:104:27-37: Base classes in `type()` calls must be a tuple literal of statically known classes [unsupported-dynamic-base]

pytest-autoprofile (https://gitlab.com/TTsangSC/pytest-autoprofile)
+ ERROR src/pytest_autoprofile/_doctest.py:361:38-48: Base class `type[DocTestRunner]` in `type()` call is not a statically known class [unsupported-dynamic-base]

pandera (https://github.com/pandera-dev/pandera)
+ ERROR pandera/api/dataframe/model.py:340:55-58: Base class `type[Self@DataFrameModel]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/dataframe/model.py:507:32-42: Base class `type[BaseConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/pyspark/model.py:214:55-58: Base class `type[Self@DataFrameModel]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/xarray/model.py:263:32-42: Base class `type[DataArrayConfig | DataTreeConfig | DatasetConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/xarray/model.py:469:42-52: Base class `type[DataArrayConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/xarray/model.py:571:42-52: Base class `type[DatasetConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pandera/api/xarray/model.py:684:42-52: Base class `type[DataTreeConfig]` in `type()` call is not a statically known class [unsupported-dynamic-base]

ibis (https://github.com/ibis-project/ibis)
+ ERROR ibis/expr/operations/udf.py:155:51-60: Base class `[B: Value[Unknown]](self: Self@_UDF) -> type[B]` in `type()` call is not a statically known class [unsupported-dynamic-base]

strawberry (https://github.com/strawberry-graphql/strawberry)
+ ERROR strawberry/tools/merge_types.py:35:39-44: Base classes in `type()` calls must be a tuple literal of statically known classes [unsupported-dynamic-base]
+ ERROR strawberry/types/base.py:341:14-25: Base class `type[Any]` in `type()` call is not a statically known class [unsupported-dynamic-base]

pandas (https://github.com/pandas-dev/pandas)
+ ERROR pandas/tseries/holiday.py:656:34-44: Base class `type[AbstractHolidayCalendar] | Unknown` in `type()` call is not a statically known class [unsupported-dynamic-base]

hydpy (https://github.com/hydpy-dev/hydpy)
+ ERROR hydpy/core/modeltools.py:3244:61-74: Base class `type[Any]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR hydpy/core/testtools.py:1401:47-55: Base class `type[T_inv]` in `type()` call is not a statically known class [unsupported-dynamic-base]

dd-trace-py (https://github.com/DataDog/dd-trace-py)
+ ERROR ddtrace/vendor/debtcollector/moves.py:192:39-48: Base class `type[Any]` in `type()` call is not a statically known class [unsupported-dynamic-base]

werkzeug (https://github.com/pallets/werkzeug)
+ ERROR src/werkzeug/test.py:819:32-48: Base class `type[Response]` in `type()` call is not a statically known class [unsupported-dynamic-base]

core (https://github.com/home-assistant/core)
+ ERROR homeassistant/helpers/deprecation.py:110:14-23: Base class `type` in `type()` call is not a statically known class [unsupported-dynamic-base]

pydantic (https://github.com/pydantic/pydantic)
+ ERROR pydantic/v1/config.py:183:27-39: Base classes in `type()` calls must be a tuple literal of statically known classes [unsupported-dynamic-base]
+ ERROR pydantic/v1/schema.py:1094:50-55: Base class `type[SecretBytes | SecretStr]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pydantic/v1/schema.py:1101:54-59: Base class `type[str]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR pydantic/v1/schema.py:1110:54-59: Base class `type[bytes]` in `type()` call is not a statically known class [unsupported-dynamic-base]

artigraph (https://github.com/artigraph/artigraph)
+ ERROR src/arti/internal/mappings.py:111:49-52: Base class `type[Self@TypedBox]` in `type()` call is not a statically known class [unsupported-dynamic-base]
+ ERROR src/arti/types/__init__.py:341:18-21: Base class `type[Self@_ScalarClassTypeAdapter]` in `type()` call is not a statically known class [unsupported-dynamic-base]

@github-actions

Copy link
Copy Markdown

Primer Diff Classification

❌ 19 regression(s) | 19 project(s) total | +36 errors

19 regression(s) across mkdocs, steam.py, spark, scikit-build-core, bidict, psycopg, schemathesis, beartype, pytest-autoprofile, pandera, ibis, strawberry, pandas, hydpy, dd-trace-py, werkzeug, core, pydantic, artigraph. error kinds: unsupported-dynamic-base, unsupported-dynamic-base on variable bases, unsupported-dynamic-base on computed tuple. caused by check_dynamic_type_bases(), is_any().

Project Verdict Changes Error Kinds Root Cause
mkdocs ❌ Regression +2 unsupported-dynamic-base check_dynamic_type_bases()
steam.py ❌ Regression +2 unsupported-dynamic-base on variable bases check_dynamic_type_bases()
spark ❌ Regression +2 unsupported-dynamic-base check_dynamic_type_bases()
scikit-build-core ❌ Regression +1 unsupported-dynamic-base check_dynamic_type_bases()
bidict ❌ Regression +1 unsupported-dynamic-base pyrefly/lib/alt/call.rs
psycopg ❌ Regression +3 array.py getattr + Any base check_dynamic_type_bases()
schemathesis ❌ Regression +1 unsupported-dynamic-base pyrefly/lib/alt/call.rs
beartype ❌ Regression +1 unsupported-dynamic-base pyrefly/lib/alt/call.rs
pytest-autoprofile ❌ Regression +1 unsupported-dynamic-base check_dynamic_type_bases()
pandera ❌ Regression +7 type[Self@DataFrameModel] dynamic base check_dynamic_type_bases()
ibis ❌ Regression +1 unsupported-dynamic-base pyrefly/lib/alt/call.rs
strawberry ❌ Regression +2 unsupported-dynamic-base on non-literal tuple (merge_types.py) check_dynamic_type_bases()
pandas ❌ Regression +1 unsupported-dynamic-base on type[X] parameter pyrefly/lib/alt/call.rs
hydpy ❌ Regression +2 unsupported-dynamic-base is_any()
dd-trace-py ❌ Regression +1 unsupported-dynamic-base is_any()
werkzeug ❌ Regression +1 unsupported-dynamic-base check_dynamic_type_bases()
core ❌ Regression +1 unsupported-dynamic-base on legitimate metaprogramming check_dynamic_type_bases()
pydantic ❌ Regression +4 unsupported-dynamic-base check_dynamic_type_bases()
artigraph ❌ Regression +2 unsupported-dynamic-base on classmethod cls parameter check_dynamic_type_bases()
Detailed analysis

❌ Regression (19)

mkdocs (+2)

Both errors flag the idiomatic pattern type(name, (cls,), dict(...)) inside __class_getitem__, where cls is the class itself. __class_getitem__ is implicitly a classmethod in Python, so cls receives the class. Pyrefly infers cls as type[Self@SubConfig] and type[Self@BasePlugin] respectively. While type[Self@X] is not a concrete class literal, it is constrained to be X or a subclass of X — meaning the base class in the type() call is always a valid class at runtime. This is a standard Python metaprogramming pattern for parameterizing classes (creating specialized subclasses dynamically), and it works correctly at runtime. The unsupported-dynamic-base rule is too strict here — type[Self@X] is sufficiently constrained that the base class is always a known class hierarchy member, even if it's not a single static class literal. The rule should either recognize type[Self] parameters from implicit classmethods like __class_getitem__ as valid bases, or only flag truly unresolvable bases where the type provides no class information at all.
Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs checks each element of the bases tuple in a 3-arg type() call. It only allows Type::ClassDef(_), Any, or error types. The cls parameter in __class_getitem__ has type type[Self@X], which is not a ClassDef variant, so it gets flagged. The check is too coarse — it doesn't distinguish between truly dynamic bases (arbitrary variables) and classmethod cls parameters that are well-typed.

steam.py (+2)

unsupported-dynamic-base on variable bases: The error on _gc/client.py:74 flags self._GC_BASES which is typed as tuple[type[GCState[Any]], ...]. While the element type is known, the tuple is variadic (unknown length), so the type checker cannot enumerate the specific base classes to construct a proper class type. This is a pyrefly-only error (pyright does not flag it). The pattern is valid Python but the type checker cannot fully resolve the resulting class hierarchy.
unsupported-dynamic-base on computed tuple: The error on enums.py:120 flags tuple(dict.fromkeys(...)) which is a computed tuple of metaclasses. This is a deliberate metaclass manipulation pattern. Pyright also flags this line (as noted in the error metadata 'pyright: yes'). The type checker cannot statically determine the contents of a tuple constructed via dict.fromkeys(). While the code is valid Python, both pyrefly and pyright cannot resolve the dynamic bases, making this a shared limitation across type checkers rather than a pyrefly-specific issue.

Overall: Both errors flag the use of non-literal tuple expressions as bases in type() calls. The unsupported-dynamic-base rule requires that type() calls use tuple literals for bases so the type checker can statically resolve the class hierarchy.

  1. steam/_gc/client.py:74: self._GC_BASES is typed as Final[tuple[type[GCState[Any]], ...]] — the type system knows these are class types, but it's a variadic tuple (unknown length), so the checker cannot enumerate the specific base classes. The code type('GCState', self._GC_BASES, {}) is a standard dynamic class creation pattern. Requiring a tuple literal here would make it impossible to use type() with computed bases, which is the entire point of the 3-arg type() form. This is pyrefly-only (pyright does not flag it).

  2. steam/enums.py:120: The tuple(dict.fromkeys([...])) expression computes bases dynamically for metaclass creation — this is an advanced but valid metaclass pattern. The code deliberately constructs a new metaclass with reordered bases. Pyright also flags this line. The type checker cannot statically determine the contents of the tuple since it's constructed via dict.fromkeys() at runtime.

The rule as implemented is strict — it bans ALL non-literal tuple expressions as bases in type() calls, even when the types may be partially known through type annotations. Dynamic type() calls are inherently dynamic; requiring literal tuples limits their usefulness. However, this is a reasonable type-checking limitation since the checker genuinely cannot verify the class hierarchy for non-literal bases. Pyright shares this limitation for the second case.

Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs is directly responsible. It requires that the second argument to 3-arg type() calls be a tuple literal (not a variable), and that each element in the tuple resolves to a Type::ClassDef. For _gc/client.py:74, self._GC_BASES is a variable (not a tuple literal), so it triggers the 'must be a tuple literal' branch. For enums.py:120, the bases argument is a tuple(dict.fromkeys(...)) call (not a tuple literal), so it also triggers the same branch.

spark (+2)

The new unsupported-dynamic-base check is too strict for union types. holder_clazz: Type[Union[NameTypeHolder, IndexNameTypeHolder]] represents a finite set of two concrete, statically defined classes. Both NameTypeHolder (line 134) and IndexNameTypeHolder (line 128) are module-level class definitions. The check should recognize that a union of known class types is still statically resolvable. This is a false positive — the code is correct and intentional.
Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs iterates over base elements and checks if each has type Type::ClassDef(_). A type[IndexNameTypeHolder | NameTypeHolder] union doesn't match the ClassDef variant, so it's flagged. The check should be extended to handle unions where every member is a statically known class.

scikit-build-core (+1)

This is a false positive. The code passes distclass (typed type[_DistributionT]) as a base class in a type() call. A value of type type[X] is guaranteed to be a class at runtime, so using it as a base in type() is perfectly valid. The new unsupported-dynamic-base rule is too strict here — it only accepts Type::ClassDef (i.e., literal class references like Base) but rejects type[X] typed parameters, which are also statically known to be classes. This is a legitimate and common Python pattern (passing a class parameter to type() for dynamic subclass creation). Neither mypy nor pyright flag this, and the typing spec does not require this check. The result wrapped in cast() further shows the developer is aware of the type system limitations here. This is a regression — pyrefly is introducing a false positive on valid code.
Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs checks that each base in a type() call is either Any, an error type, or a Type::ClassDef. A parameter typed as type[_DistributionT] resolves to a type[_DistributionT] type (not a ClassDef), so it fails the check and triggers the error. The condition matches!(base_ty, Type::ClassDef(_)) is too narrow — it doesn't account for type[X] typed variables that are valid class objects at runtime.

bidict (+1)

This is a false positive / too-strict check. The code at line 148 is inv_cls = type(f'{cls.__name__}Inv', (cls, GeneratedBidictInverse), diff) inside a @classmethod _make_inv_cls. Here cls is the class itself (the first parameter of a classmethod), which at runtime IS a concrete class object. The type of cls is type[Self@BidictBase], which pyrefly doesn't recognize as a 'statically known class' because its check only passes Type::ClassDef(_) through. But type[Self] in a classmethod IS a class — it's just that the type checker represents it differently than a bare class reference. Neither mypy nor pyright flag this. The bidict project is well-tested and this dynamic class creation pattern is a core feature of the library (documented in their extending guide). Flagging this as an error provides no value and would require the project to suppress a false positive.
Attribution: The new check_dynamic_type_bases function in pyrefly/lib/alt/call.rs checks each base in a type() call and errors if the base type is not Type::ClassDef(_). In this case, cls inside a @classmethod of a generic class has type type[Self@BidictBase], which is not a bare ClassDef — it's a parameterized type wrapper. The check matches!(base_ty, Type::ClassDef(_)) fails for type[Self], so it emits the error. The test case in pyrefly/lib/test/calls.rs confirms this is intentional behavior: type[Base] is flagged because it's not a statically known class literal. However, this is overly strict for real-world code.

psycopg (+3)

array.py getattr + Any base: Borderline correct — getattr(_psycopg, 'ArrayLoader', ArrayLoader) returns type[ArrayLoader] | Any, which is genuinely not fully statically known. Pyright agrees. However, this is a very common pattern for optional C extensions.
json.py type[X] parameter as base: False positives — type[Dumper] and type[Loader] are standard type[X] annotations representing class objects. The check incorrectly rejects them because it only accepts Type::ClassDef literals. Neither mypy nor pyright flags these. The check in check_dynamic_type_bases() needs to also accept type[X] as valid.

Overall: 2 of 3 errors are false positives. The json.py errors flag type[Dumper] and type[Loader] — these are standard type[X] annotations representing class objects. The PR's check_dynamic_type_bases is too strict: it only allows Type::ClassDef (a literal class reference) but rejects type[X] parameters, which are the standard way to pass classes as arguments. The array.py error is more defensible since getattr genuinely introduces Any, but even that is a common pattern for optional C extensions. Overall, the new check needs to also accept type[X] as a valid base class.

Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs only accepts Type::ClassDef(_), Any, or error types as valid bases. It does not accept type[X] (a class type parameter), which causes the false positives on json.py:120 and json.py:140. The array.py:339 error is borderline — the getattr introduces Any into the union, making it genuinely non-static.

schemathesis (+1)

This is a false positive. The code on line 137 uses type('YAMLLoader', (SafeLoader,), {}) where SafeLoader is either CSafeLoader or SafeLoader depending on which import succeeded (lines 132-135). Both are concrete, statically known classes — the only reason pyrefly can't resolve it is that the variable's type is a union type[CSafeLoader] | type[SafeLoader]. Each branch of the union is a perfectly valid, statically known class. The check_dynamic_type_bases function in the PR only accepts Type::ClassDef(_) as valid, but a union of class types should also be accepted since every possible runtime value is a valid base class. This is a common Python pattern (conditional C extension imports) that works correctly at runtime and is not flagged by mypy or pyright. The new rule is too strict in not handling unions of class types.
Attribution: The new check_dynamic_type_bases method in pyrefly/lib/alt/call.rs is responsible. Specifically, the check at lines 1273-1282 infers the type of each base expression in the type() call's tuple. The variable SafeLoader on line 137 has the inferred type type[CSafeLoader] | type[SafeLoader] (a union due to the try/except import pattern on lines 132-135). Since this union type does not match Type::ClassDef(_) (it's a union of two class types, not a single class definition), the check falls through to the error case and reports unsupported-dynamic-base. The check is too strict — it doesn't handle union types where all branches are valid class types.

beartype (+1)

This is a false positive. The beartype code is using the 3-argument type() call correctly — type_bases is typed as TupleTypes (which is Tuple[type, ...]) and is a perfectly valid bases argument. The new pyrefly rule requires a tuple literal at the call site, but this is an overly strict syntactic requirement. Passing a variable of the correct type is standard Python practice for dynamic class creation. Neither mypy nor pyright flag this. The PR's own test case shows that type("AlsoDynamic", bases, {}) is flagged even when bases is a well-typed tuple — this confirms the check is syntactic rather than type-based. While pyrefly may want to flag truly unresolvable bases for better type inference, flagging a well-typed tuple[type, ...] variable as an error is too strict and produces false positives on legitimate code patterns like beartype's make_type utility.
Attribution: The new check_dynamic_type_bases function in pyrefly/lib/alt/call.rs requires that the second argument to 3-argument type() calls be a tuple literal (Expr::Tuple). When the argument is a variable reference (like type_bases), it emits the UnsupportedDynamicBase error. The check at the top of the function (let Expr::Tuple(tuple) = bases else { ... error ... }) is purely syntactic — it rejects any non-literal tuple expression, even when the type is known to be Tuple[type, ...].

pytest-autoprofile (+1)

This is a new pyrefly-specific check that flags a valid and common Python metaprogramming pattern. The code type('Dynamic', (base,), {}) where base: type[DocTestRunner] is perfectly valid Python. pytest uses this pattern intentionally for dynamic class creation in its doctest module. Neither mypy nor pyright flags this. The typing spec does not require this check.

While the PR's intent is to warn about patterns that pyrefly can't statically analyze, flagging well-established patterns in widely-used projects like pytest creates false positive noise. The check is arguably reasonable from a strict static analysis perspective: type[DocTestRunner] means the variable holds some class that is DocTestRunner or a subclass thereof, but the exact class is not statically determined — it could be any subclass. This means the type checker cannot fully resolve the MRO or attributes of the dynamically created class. However, this is an intentional metaprogramming pattern, and the practical impact of not being able to statically analyze the resulting class is minimal in this context. The check is too strict for real-world codebases that rely on this well-established idiom.

Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs is directly responsible. It checks that when type(name, bases, dict) is called with 3 arguments, each element in the bases tuple must be a Type::ClassDef(_) (a statically known class literal). A variable of type type[DocTestRunner] resolves to a Type that is not ClassDef — it's a type variable or a type[X] wrapper — so it gets flagged. The condition if base_ty.[is_any()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || base_ty.[is_error()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || matches!(base_ty, Type::ClassDef(_)) only allows bare class references like Base, not variables typed as type[Base].

pandera (+7)

type[Self@DataFrameModel] dynamic base: cls: type[Self] in class_getitem is a valid class object at runtime. Flagging it is overly strict — the type checker could recognize type[X] as a valid base. 2/3 co-reported by pyright, but mypy doesn't flag any.
type[BaseConfig] dynamic base: cls.Config is typed as type[BaseConfig] — a concrete class type. Using it as a base in type() is standard Python. Pyrefly-only error, neither mypy nor pyright flag it.
xarray config dynamic bases: type[DataArrayConfig | DataTreeConfig | DatasetConfig] etc. are concrete config class types used as bases. All 4 errors are pyrefly-only. Standard metaprogramming pattern, not a bug.

Overall: These are false positives. The code uses type(name, (cls,), dict) and type(name, (cls.Config,), dict) — standard Python metaprogramming patterns where cls and cls.Config are runtime class objects typed as type[X]. The new rule is too strict: it requires class literals but type[X] values are guaranteed to be valid classes at runtime. This creates noise in real projects (pandera) without catching any bugs. 5/7 errors are pyrefly-only, and mypy flags none of them.

Per-category reasoning:

  • type[Self@DataFrameModel] dynamic base: cls: type[Self] in class_getitem is a valid class object at runtime. Flagging it is overly strict — the type checker could recognize type[X] as a valid base. 2/3 co-reported by pyright, but mypy doesn't flag any.
  • type[BaseConfig] dynamic base: cls.Config is typed as type[BaseConfig] — a concrete class type. Using it as a base in type() is standard Python. Pyrefly-only error, neither mypy nor pyright flag it.
  • xarray config dynamic bases: type[DataArrayConfig | DataTreeConfig | DatasetConfig] etc. are concrete config class types used as bases. All 4 errors are pyrefly-only. Standard metaprogramming pattern, not a bug.

Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs checks that each base in a 3-arg type() call is Type::ClassDef(_), Any, or error. Values of type type[X] (which are Type::ClassType not Type::ClassDef) fail this check, causing all 7 errors. The check is triggered by the new match arm at line 2044-2049 that detects builtins.type called with 3 args.

ibis (+1)

This is a correct new error. Looking at the code: _UDF._base is an abstract property declared on line 95-97 with return type type[B], where B is a module-level TypeVar (line 89), not a class-level generic parameter (_UDF is not declared as Generic[B]). When accessed as cls._base in the classmethod _make_node (line 155), the type checker resolves this through the abstract property's return type annotation, which involves a TypeVar and is therefore not a statically known class literal. The type() call type(_make_udf_name(fn.__name__), (cls._base,), fields) uses this dynamic base to create a class at runtime — this is intentional metaprogramming that type checkers cannot statically analyze.

In practice, _make_node is only called on concrete subclasses scalar (line 171) and agg (line 671), where _base is overridden as a class attribute set to ScalarUDF and AggUDF respectively (lines 179 and 681). However, since _make_node is defined on _UDF and the type checker analyzes it in that context, it sees the abstract property's return type rather than the concrete overrides in subclasses. The type checker correctly identifies that it cannot verify the class hierarchy statically when analyzing the method as defined on the base class. This is a legitimate limitation of static type checking when encountering dynamic type() calls with non-literal base classes.

Attribution: The new check_dynamic_type_bases method in pyrefly/lib/alt/call.rs is responsible. It checks that when type() is called with 3 arguments, the second argument (bases) must be a tuple literal of statically known classes (i.e., Type::ClassDef). On line 155, cls._base is an abstract property returning type[B] where B is a TypeVar — this resolves to a type[B] type rather than a concrete ClassDef, so the check correctly flags it as not statically known.

strawberry (+2)

unsupported-dynamic-base on non-literal tuple (merge_types.py): False positive. The variable types has type tuple[type, ...] and contains valid class objects. Requiring a tuple literal is too strict — this is a standard dynamic class creation pattern. Pyrefly-only error.
unsupported-dynamic-base on type[Any] base (base.py): False positive / too strict. self.origin is type[Any], which IS a class object. The check should recognize type[X] as a valid base class in type() calls. While pyright also flags this, the pattern is intentional and fundamental to Strawberry's architecture.

Overall: Both errors are new false positives from a new lint rule that is too strict for real-world Python code.

Error 1 (merge_types.py:35): The function merge_types takes types: tuple[type, ...] and calls type(name, types, {}). This is a standard dynamic class creation pattern. The variable types contains actual class objects at runtime. Pyrefly flags this because types is not a tuple literal, but this is an intentional and correct use of type(). Neither mypy nor pyright flag this.

Error 2 (base.py:341): self.origin is typed as type[Any] (line 278). The code type(new_type_definition.name, (self.origin,), {...}) creates a new class inheriting from self.origin. While type[Any] is not a concrete class literal, it IS a class object — it's typed as type[Any] meaning 'some class that is or inherits from Any'. This is a fundamental pattern in Strawberry's generic type system. Pyright does flag this (suggesting some agreement), but the pattern is intentional and works correctly.

The new rule is overly strict for real-world code. Dynamic class creation with type() is a well-established Python pattern, and requiring all bases to be statically known class literals eliminates many legitimate use cases. The rule as implemented doesn't account for type[X] variables (which ARE class objects) or tuple variables containing classes.

Per-category reasoning:

  • unsupported-dynamic-base on non-literal tuple (merge_types.py): False positive. The variable types has type tuple[type, ...] and contains valid class objects. Requiring a tuple literal is too strict — this is a standard dynamic class creation pattern. Pyrefly-only error.
  • unsupported-dynamic-base on type[Any] base (base.py): False positive / too strict. self.origin is type[Any], which IS a class object. The check should recognize type[X] as a valid base class in type() calls. While pyright also flags this, the pattern is intentional and fundamental to Strawberry's architecture.

Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs is directly responsible for both errors. The function is invoked when type() is called with 3 arguments. For error 1 (merge_types.py line 35), the bases argument is types which is a variable (not a tuple literal), so the first check fires: 'Base classes in type() calls must be a tuple literal of statically known classes'. For error 2 (base.py line 341), the bases argument is (self.origin,) which IS a tuple literal, but self.origin has type type[Any], and the check base_ty.[is_any()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || base_ty.[is_error()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || matches!(base_ty, Type::ClassDef(_)) fails because type[Any] is not Any itself, not an error type, and not a ClassDef — it's a type[Any]. So the second check fires.

pandas (+1)

unsupported-dynamic-base on type[X] parameter: The parameter base_class has no type annotation — it only has a default value of AbstractHolidayCalendar. Because it is untyped, pyrefly infers its type as type[AbstractHolidayCalendar] | Unknown (as shown in the error message). The | Unknown component is what prevents the checker from treating this as a statically known class. If the parameter were explicitly annotated as base_class: type[AbstractHolidayCalendar] = AbstractHolidayCalendar, the checker might accept it. The practical fix is to add a type annotation. While the code is correct at runtime and the pattern is common, the checker's complaint about | Unknown is technically defensible — without a type annotation, it cannot fully guarantee the value is a valid base class. This is a borderline case: arguably a false positive in practice (the default value makes the intent clear), but the root cause is the missing type annotation rather than an inherent flaw in the unsupported-dynamic-base check.

Overall: The HolidayCalendarFactory function on line 654 has parameter base_class=AbstractHolidayCalendar with no type annotation. Because it is untyped, pyrefly infers its type as type[AbstractHolidayCalendar] | Unknown (as shown in the error message). The | Unknown component is what makes this not a statically known class — the checker cannot guarantee that base_class is definitely a class at the point of use in type(name, (base_class,), {...}). If the parameter were explicitly annotated as base_class: type[AbstractHolidayCalendar] = AbstractHolidayCalendar, the behavior of the checker might differ. That said, this is still arguably a false positive in practical terms — the default value clearly establishes the intended type, and using type[X] values as bases in type() calls is a common and correct Python pattern. The fix would be to add a proper type annotation to the base_class parameter. However, the checker's behavior is technically defensible: with | Unknown in the type, it cannot statically guarantee the value is a valid base class.

Attribution: The new check_dynamic_type_bases method in pyrefly/lib/alt/call.rs checks each base in a type() call's bases tuple. It flags any base whose type is not Type::ClassDef(_) (i.e., not a statically known class literal). On line 654, base_class has the type type[AbstractHolidayCalendar] (because it's a parameter with default AbstractHolidayCalendar), which is not Type::ClassDef — it's a type[X] type variable. The check at lines 1271-1280 in call.rs sees type[AbstractHolidayCalendar] | Unknown and flags it because it doesn't match Type::ClassDef(_).

hydpy (+2)

The analysis is factually correct. type[X] is indeed the standard typed representation of class objects per the typing spec. In modeltools.py line 3244, typesequences is typed as type[Any] (from the infos tuple annotation) and represents a concrete class like InletSequences. In testtools.py line 1401, abstract is typed as type[T_inv] from the function signature. Both are valid class objects that can serve as bases in 3-argument type() calls. The claim that neither mypy nor pyright flags these patterns is accurate - both tools accept type[X] variables as bases in dynamic class creation via type().
Attribution: The new check_dynamic_type_bases method in pyrefly/lib/alt/call.rs checks that each base in a 3-arg type() call resolves to Type::ClassDef, Any, or error. Variables typed as type[X] resolve to a different Type variant and are incorrectly flagged. The check at line if base_ty.[is_any()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || base_ty.[is_error()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || matches!(base_ty, Type::ClassDef(_)) is too narrow — it should also accept type[X] types.

dd-trace-py (+1)

This is a new pyrefly-specific lint rule that flags type() calls with non-literal class bases. The code at line 192 uses type(old_class_name, (new_class,), {}) where new_class is a parameter with no type annotation. The inspect.isclass() check at line 170 validates at runtime that new_class is indeed a class, but this runtime check doesn't narrow the type for static analysis purposes.

Since new_class has no type annotation, pyrefly apparently infers or resolves its type in the tuple context as type[Any], as indicated by the error message. The unsupported-dynamic-base rule then flags this because type[Any] is not a statically known class literal — it's a variable whose concrete class identity cannot be determined at compile time.

This is a completely valid and common Python pattern for dynamic class creation. The three-argument type() call accepts any class objects as bases, and passing a variable holding a class reference is standard practice, especially in factory/deprecation wrapper patterns like moved_class. Neither mypy nor pyright flag this pattern.

The rule is intentionally strict — it requires bases in type() calls to be statically resolvable class references. This means legitimate patterns where class objects are passed as parameters (like this deprecation utility) will be flagged. The code is correct and intentionally dynamic; this is a false positive from the perspective of code correctness, though it reflects a deliberate design choice in the lint rule to warn about any non-literal base class.

Attribution: The new check_dynamic_type_bases method in pyrefly/lib/alt/call.rs checks each element in the bases tuple of a 3-argument type() call. When base_ty is type[Any] (the inferred type of new_class parameter which has no annotation), it doesn't match Type::ClassDef(_) and isn't is_any() or is_error(), so it falls through to the error. The new_class parameter in moved_class has no type annotation, so pyrefly infers it as having some type. Looking at the error message type[Any], the parameter is untyped so pyrefly treats it as Any, and type[Any] is the type of the expression new_class in the bases tuple. The check in check_dynamic_type_bases allows is_any() but type[Any] is not Any itself — it's a specific generic type — so it gets flagged.

werkzeug (+1)

This is a false positive. The code uses type[Response] — a well-typed class variable — as a base in a type() call. This is a standard Python pattern for dynamic class creation with known base types. The new unsupported-dynamic-base rule is too strict: it flags any non-literal class reference, even when the type is fully known via type[X] annotations. Neither mypy nor pyright flag this. The rule should allow type[X] values as valid bases, not just class literals (Type::ClassDef).
Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs checks each element of the bases tuple and flags anything that isn't Type::ClassDef, Any, or Error. A type[Response] variable resolves to a type like type[Response] (not Type::ClassDef), so it gets flagged. The check is too coarse — it doesn't distinguish between truly dynamic/unknown bases and well-typed type[X] parameters.

core (+1)

unsupported-dynamic-base on legitimate metaprogramming: The code uses type(cls) to get the metaclass of cls (where cls: type[_T]), storing it in base_meta. It then creates a new metaclass via type(name, (base_meta,), dict). Pyrefly flags base_meta because it is a variable rather than a class literal, making the base class 'not statically known'. However, this is a standard Python metaprogramming pattern for creating derived metaclasses. The pattern is valid Python and is not flagged by mypy or pyright. This is a pyrefly-only rule with no typing spec backing, and it is too strict for this legitimate use case.

Overall: This is a new pyrefly-specific lint rule that flags dynamic base classes in type() calls. The error occurs because base_meta on line 100 is assigned type(cls), which returns the metaclass of cls at runtime. Since cls is typed as type[_T], type(cls) returns type[type[_T]]. Pyrefly cannot statically determine what concrete class this will be, so it flags the use of base_meta in the type() call on line 108-112 as a non-statically-known base class.

However, this is a false positive. The pattern of using type(cls) to obtain the metaclass and then creating a derived metaclass via type(name, (base_meta,), dict) is a standard and well-established Python metaprogramming idiom. It is used here to create a wrapper metaclass that overrides __call__ to inject deprecation warnings. Neither mypy nor pyright flag this pattern, and no typing spec rule prohibits it.

The rule is too strict — it rejects any non-literal class expression in type() bases, which catches legitimate metaprogramming patterns that are common in production Python code.

Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs checks that each element in the bases tuple of a 3-arg type() call resolves to Type::ClassDef(_), Any, or error. Since base_meta has type type[type[_T]] (a type[X] instance, not a bare ClassDef), it fails this check. The matching logic at line ~2044 triggers this check for any 3-arg type() call on builtins.type.

pydantic (+4)

These are false positives. The type() 3-argument form is a standard Python metaprogramming pattern used extensively in frameworks. Pydantic v1 uses it for config inheritance (inherit_config) and constraint type generation (get_annotation_with_constraints). The code is correct and works at runtime. Neither mypy nor pyright flags these. The new rule is too strict — it flags legitimate dynamic class creation patterns that are common across the Python ecosystem. While it's reasonable for a type checker to note it cannot fully analyze such classes, reporting them as errors creates noise on well-tested codebases.
Attribution: The new check_dynamic_type_bases() method in pyrefly/lib/alt/call.rs introduces the UnsupportedDynamicBase error kind. It fires when type() is called with 3 args and the bases argument is either not a tuple literal, or contains elements whose inferred type is not Type::ClassDef. In pydantic's case, the bases are variables (base_classes) or parameters (type_) whose types are type[X] rather than class literals, triggering the error.

artigraph (+2)

unsupported-dynamic-base on classmethod cls parameter: Both errors flag cls (typed as type[Self@TypedBox] and type[Self@_ScalarClassTypeAdapter] respectively) used as a base in type() calls within classmethods. This is a standard Python metaprogramming pattern. The check_dynamic_type_bases function only allows Type::ClassDef(_) but type[Self] is not represented as a bare ClassDef internally — it's a parameterized type wrapper. Since cls in a classmethod is guaranteed to be a class object (it's the class or a subclass), using it as a base in type() is valid. These are false positives from an overly strict rule that doesn't account for type[Self] being a legitimate class reference.

Overall: These are false positives. The new unsupported-dynamic-base rule is too strict — it flags type[Self@TypedBox] and type[Self@_ScalarClassTypeAdapter] (the type of cls in classmethods) as "not a statically known class," but cls in a classmethod IS a class. While the exact class isn't known at static analysis time (it could be a subclass), this is a standard, well-established Python pattern for dynamic class creation in classmethods. The rule's intent (catching truly dynamic bases like variables of unknown type) is reasonable, but the implementation incorrectly rejects type[Self] patterns, which represent class objects that are valid bases for type() calls. These are legitimate metaprogramming patterns that should not be flagged.

Attribution: The new check_dynamic_type_bases() function in pyrefly/lib/alt/call.rs checks each base in a type() call and flags anything that isn't Type::ClassDef(_), Any, or an error type. The problem is that cls in a classmethod has type type[Self@TypedBox] (or type[Self@_ScalarClassTypeAdapter]), which is a type[X] — not a bare ClassDef. The check matches!(base_ty, Type::ClassDef(_)) doesn't account for type[Self] or type[X] patterns, which are perfectly valid base classes. The condition is too narrow, causing false positives on legitimate classmethod patterns where cls is used as a base in type() calls.

Suggested fixes

Summary: The new check_dynamic_type_bases() function in call.rs is too strict: it only accepts Type::ClassDef(_) as valid bases in type() calls, but rejects type[X] values (including classmethod cls parameters, type[Self], and type[X] function parameters), unions of class types, and non-literal tuple variables — all of which are valid class objects at runtime. This causes 19 regressions across 19 projects, with the vast majority being pyrefly-only false positives.

1. In check_dynamic_type_bases() in pyrefly/lib/alt/call.rs, expand the acceptance condition on line ~1273 from base_ty.[is_any()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || base_ty.[is_error()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || matches!(base_ty, Type::ClassDef(_)) to also accept type[X] types (Type::ClassType or however pyrefly represents type[X]). Specifically, when base_ty represents type[X] for any X (including type[Self@Foo], type[Any], type[SomeClass], type[T] for TypeVars), it should be accepted as a valid base class since type[X] is guaranteed to be a class object at runtime. Additionally, handle union types where all members are valid bases (either ClassDef or type[X]). Pseudo-code: if base_ty.[is_any()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || base_ty.[is_error()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/call.rs) || matches!(base_ty, Type::ClassDef(_)) || is_type_of_class(base_ty) || (is_union(base_ty) && all_members_are_valid_bases(base_ty)) { continue; } where is_type_of_class checks if the type is type[X] for some X.

Files: pyrefly/lib/alt/call.rs
Confidence: high
Affected projects: mkdocs, scikit-build-core, bidict, psycopg, schemathesis, pytest-autoprofile, pandera, strawberry, pandas, hydpy, dd-trace-py, werkzeug, core, pydantic, artigraph, spark
Fixes: unsupported-dynamic-base
The overwhelming majority of regressions (at least 30 out of ~35 total errors across 19 projects) are caused by type[X] values being rejected as bases. type[X] is the standard Python typing representation of 'a class object that is X or a subclass of X' — it is always a valid class at runtime and can always serve as a base in type() calls. At least 25 of these errors are pyrefly-only (not flagged by mypy or pyright), confirming they are false positives. Accepting type[X] as a valid base would eliminate errors across mkdocs (2), scikit-build-core (1), bidict (1), psycopg (2), schemathesis (1), beartype (0 - separate issue), pytest-autoprofile (1), pandera (7), strawberry (1), pandas (1), hydpy (2), dd-trace-py (1), werkzeug (1), core (1), pydantic (4), artigraph (2), and spark (2 via union handling).

2. In check_dynamic_type_bases() in pyrefly/lib/alt/call.rs, relax the requirement that the bases argument must be an Expr::Tuple literal. When the bases argument is a variable (not a tuple literal), instead of immediately erroring, check if its inferred type is a tuple type (e.g., tuple[type, ...] or tuple[type[X], ...]). If the element types of the tuple are valid base classes (ClassDef or type[X]), accept it without error. Only emit the error when the variable's type is completely unknown or contains non-class elements. Pseudo-code: let Expr::Tuple(tuple) = bases else { let bases_ty = self.expr_infer(bases, errors); if is_tuple_of_valid_bases(bases_ty) { return; } self.error(...); return; };

Files: pyrefly/lib/alt/call.rs
Confidence: high
Affected projects: beartype, steam.py, strawberry, pydantic
Fixes: unsupported-dynamic-base
Several regressions are caused by the syntactic requirement that bases must be a tuple literal expression. In beartype, steam.py, strawberry (merge_types.py), and pydantic, well-typed tuple variables like tuple[type, ...] or tuple[type[X], ...] are passed as the bases argument. These are valid at runtime and the type system has enough information to know they contain class objects. Relaxing this syntactic check to also accept well-typed tuple variables would eliminate ~5 additional pyrefly-only errors.


Was this helpful? React with 👍 or 👎

Classification by primer-classifier (19 LLM)

@asukaminato0721 asukaminato0721 marked this pull request as draft June 18, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

new rule - ban dynamic types

1 participant