Limitations, stated plainly¶
numty prefers silence to a wrong claim. This page lists where it goes quiet, where it is approximate, and what to write instead.
Other type checkers¶
- The plugin is mypy-only and pinned to mypy internals that occasionally
shift.
tyand pyright users get layers 1–2, which still catch rank and fixed-size bugs. They do not get theF64-style heads: ty 0.0.97 rejects correct calls into head-annotated functions too (a(2, 3)array passed toF64[batch, features]is aninvalid-argument-typeerror; #5). -
Adopting numty in a repo that gates on ty (or pyright) alone: the heads give that gate no shape checking at all. Every call that passes an
ndarrayinto anF64[...]signature is an error there, correct or not, so the heads cost the full annotation effort and catch nothing. Shapes that must hold under such a gate go in layers 1–2 orFlexArray, which are plainndarraytypes to every checker:# fixed rank: either spelling works under mypy+plugin, ty and pyright def f(x: FlexArray[tuple[N, T, C], np.dtype[np.float16]]) -> ...: ... def f(x: np.ndarray[tuple[N, T, C], np.dtype[np.float16]]) -> ...: ...ty checks rank,
Literalsizes and dtype through these, and the rank-genericsquared_normalso works there: ty 0.0.97 reveals(5, 7)for a(5, 7, 2)input and rejects(5, 7, 3)withinvalid-argument-type(the twotype: ignore[type-var]notes are for mypy; ty does not need them). pyright 1.1.408 flags the same two bugs in tests/fixtures/flex_portable.py, which the suite checks under mypy+plugin and ty. Neither does any solving:matmulinner dims, broadcasts and call-siteTypeVarconflicts pass silently and stay mypy+plugin only. Making the heads themselves rank-check without the plugin is still open in #5. - Without the plugin,matmulreturns a loose type: usable but unchecked.
mypy behaviour¶
- On lines the solver rejects, mypy may add its own follow-on error next
to ours (an overload complaint for tuple
reshape,Unsupported operandfor broadcast ops, aDTypeTtype-var complaint forfftwith a badn). Ours is the readable one. An index error no longer gets mypy'sInvalid index typeon top (issue 333): it is reported past the overload trial that mypy runs for a key that is not all arrays, so the trial does not fail. - Test runs use a dedicated mypy cache dir cleared per suite run (see
test_static.py): mypy does not reliably invalidate plugin results across plugin edits, so never trust the default cache while hacking on the plugin. - The
@operator solves through the same matmul hooks as rawnp.matmuland thenumty.matmulwrapper (__matmul__/__rmatmul__, with__imatmul__checked in place): wrong shapes fail with the same[numty-shape]error and correct shapes stay precise instead of dissolving toAny. -
numty needs mypy to follow imports (the default). Under
- Unannotated functions are not checked: mypy skips their bodies unless--follow-imports=skipmypy skipsnumtyitself, so everyF64[...]-annotated parameter and return becomesAnyand nothing through them is checked, with no warning. The ufunc generic aliases also stay unresolved, so barenp.sqrt-style calls bail out; operators, methods, and realdefstubs on plain numpy arrays are unaffected. If you needskip, keep followingnumtywith a per-module override, which brings the heads back (ufunc calls still bail):check_untyped_defsis on (see Get started). - An inline call to an overloaded function nested directly inside an overloaded numpy call dissolves toAny(mypy keeps the overload-inferred type over the hook result): hoist it to a local first (kernel = gaussian_kernel(...); np.where(cond, kernel, 0.0)).
Symbolic sizes¶
- A shape error on symbolic dims is about sizes in general, not about
every size.
signal[:, 1:] - signalis reported astime-1vstime, and that is wrong code for anytimeabove 2, but numpy accepts it whentimeis 0 (both sides empty), 1 (the empty side broadcasts) or 2 (a length-1 axis broadcasts). The same holds forN+1againstN. The oracle counts an error as false only when numpy accepts the expression at every set of sizes it tries. - The same holds for a size with no name that is still one size: the
count of one mask variable and a size a function chose for a name
of its return (
?K@12).np.diff(tps, n=1)againstnp.diff(tps, n=2)is reported as?K@12-1vs?K@12-2. Minted in a loop or a comprehension (a call there, a mask variable bound there, a variable holding an object bound there) such a size is a size per run and nothing is proved from it; the same goes for any other unknown (?U@14from an inline mask): one site can stand for the items of a comprehension. A value kept across calls of a function (an attribute holding last call's selection) is not seen as another size. - An in-place op that grows a
1axis to a size over names is reported:G *= K[..., np.newaxis]withGof shape(N, 1, M)andKof(N, N)fits only whenNis 1, as the literal(4, 4, 1)into(4, 1, 3)does not. - A name against an
int,Anyor unknown size in a broadcast is kept as the name (issue 333): the other side is the name or 1 when the broadcast is valid. A name that is 1 at run time broadcasts to the other size instead, which this misses, asnp.covbelow does. np.covsqueezes its result, so a single variable gives(). numty knows that for a literal 1 (np.covof(1, N)) and a vector, not for a named size that is 1 at run time:(D, N)is(D, D).- The diagonal of a non-square matrix is
min(N, M), and an offset diagonal is clamped at zero; neither is spelled over names, so they are a?Uunless the sizes are literals. - Each
np.uniquecall has its own?U.classes = np.unique(y)and_, counts = np.unique(y, return_counts=True)are two sizes, and so is the length of a list appended to once per class. A return that declares one free name for both (F64["n_classes d"]andF64["n_classes"]) is reported; say they are one with acaston one of them. - A symbolic subtraction assumes the axis is long enough.
a[1:]is(N-1,)andnp.diff(a)is(N-1,), but numpy stops at zero: on an empty axis both are(0,), not(-1,). The oracle counts a dim that comes out negative as a known gap. - Sizes that differ only by a factor are not reported outside
reshape:2*MandMare equal atM == 0, sonp.vdotof(2, M)and(M,)is not an error.reshapereads totals up to a zero factor ((2*N,)to(3, N)is an error). - At a call, a formula parameter is solved when the argument gives a
whole linear answer:
"N+1"givenM+3bindsNtoM+2, and"2*N"given2*M+4binds it toM+2."2*N"givenMdoes not (M need not be even), and the return dims that needNare unknown. So is a result dim that would be spelled exactly like one of the callee's own formula parameters over the same module-level name:def g(x: F64[N], y: F64["N+1"]) -> F64[N]called with anN+1-sizedxreturns an unknown size, notN+1.
Dynamic shapes and dtypes¶
- Dynamic shapes (
np.zeros((n, 3))) cannot be proven wrong, so they are left alone. Unknown is not an error. A builder (zeros/ones/empty/full,*_like(shape=...)) keeps the literal dims of a tuple display next to plainintones:np.zeros((n, 3))istuple[int, Literal[3]], also once assigned. A dynamic size in a directly returned value (or in an unannotated local it is returned through) isAnythere instead, so it fits a declared named size (issues #269/#271); annotated locals, attribute or destructured targets, andyielded values keep the precise type and still check. AGeneratorsampler's mixedsize=keeps the stubs'tuple[Any, ...]: its answer is final (no overload takes the shape from the assignment), so a name first bound to(n, 3, 4)could not be rebound tosize=(n, d, 4). Areshapefrom an input with no literal axis (x.ravel().reshape(n, d)) makes itsintsizes unknown for the same reason; named target sizes stay (withn: N,h: Hit is(N, H), issue 333). Most of the solver does not readintdims. The exceptions:reshapekeeps the literal sizes it is asked for whenever one of them is a plainintor the input's shape cannot be read (x.reshape(-1, 3),x.reshape(n, 3)andx.reshape(x.shape)aretuple[int, Literal[3]]; a-1next to anintsize is unknown, sox.reshape(n, -1)istuple[int, Any], and so is aninttarget size on an axis where the input had a literal:x.reshape(-1, d)istuple[Any, Any]);gradientalong one axis keeps the operand's type and still checks the axis;np.dot/np.matmul/@take anintdim as matching and keep the outer dims ((n, 3)by(3, 3)istuple[int, Literal[3]], while 3 against 4 is an error as for literal shapes; anintdim landing on an axis where an operand has a literal turns unknown, so(n, 3)by(3, k)istuple[Any, Any]). Next to named dims it is the same rule:(N, int) @ (int, H)is(N, H)and(int, N) @ (N, M)is(int, M)(issue 333,tests/fixtures/matmul_int_dim_ok.py);linalg.svdandlinalg.qrread anintdim as a size too; transposes move the literal with its axis; arithmetic, comparisons,&,|and^between two arrays keep the rank, make eachintaxis unknown and still check the named axes (a[:, 1:] | awitha: Bool[int, N]isN-1 vs N);np.searchsorted(a, v)anda.searchsorted(v)have the shape ofvas it stands. Other ops on such an array (or on one annotated that way) use numpy's stubs. Shape-keeping ones (arithmetic,astype,copy,flip, sorts along an axis, ...) keep the literal. For the rest the stubs' answer turns unknown (Any) at its literal dims, because the stubs would leave them on the wrong axis afterswapaxes,padordiff, and at itsintdims on axes where an operand had a literal (np.resize(x, x.shape)istuple[int, Any], nottuple[int, int]). Unknown still fits where the literal was expected, sox = np.diff(x, axis=0)keeps type checking, as does every rebinding intests/fixtures/mixed_rebind_ok.py. Later stub ops on such a result may answerndarray[Any, Any], losing the dtype as well. Since a transpose moves the literal, so rebinding a name to its own transpose (e = np.zeros((n, 3))thene = e.T) is an[assignment]error under default mypy, as it already was for all-literal shapes. With the recommendedallow_redefinition = Trueit checks clean (seetests/fixtures/rebind_allow_ok.py); otherwise bind the transpose to a new name. Written inline as an argument whose expected literal differs, a builder falls through to numpy's catch-all overload and goes unchecked, as before. Assign it to a name first to have it checked. - An explicit
dtype=is honored when it resolves (np.float32,"float64", bareint/float/bool, ...); exotic forms bail out. Same for unknownkeepdims.
Aliases and masked arrays¶
- A numpy function held in a local or module-level variable is
followed (
op = np.sum; op(a, axis=0)). One held in a class or instance attribute (self.op(a)), passed as aCallableparameter, or wrapped (functools.partial(np.sum, axis=0)) keeps the stubs' answer. numpy.masupport is theMaskedArraytype: its constructor, its methods and numpy functions given one follow the ndarray rules. Thenp.ma.*functions (np.ma.sum,np.ma.where, ...) keep the stubs' answer, the mask is not checked against the data, and adtype=in the constructor leaves it to the stubs. A shape error in aMaskedArrayoperator draws mypy'sUnsupported operand typesnext to ours (the suppression of that duplicate covers ndarray's operators only).
Rank polymorphism (*Ts)¶
- Rank-polymorphic helpers (
(..., 2)→...) spell the shape throughFlexArraywith one*Tssignature: each signature carries twotype: ignore[type-var]notes (thetuple[int, ...]stub bound cannot see through an unsolved*Ts), as does each builder fed a*Tsslice (np.zeros((n, *x.shape[1:]))), while bodies and call sites check fully. The familiar heads need none of these: they mark their*Tsint-only. mypy itself rejectsshape[1:]on a bare(*Ts,)(ambiguous variadic slice); name the leading axis. A provable*Tserror may draw the stubs' own echo next to ours (an[index]complaint on unpack shapes); ours names the axis, the size, and the(*Ts, ...)shape. - Rank arithmetic over
*Tsnever reorders the unknown block:transpose/.Tbail out (no annotation can spell its reversal), explicit transpose axes bail, and areshape-1bails because the inferred dim would absorb the*Tsproduct. A non-ellipsis index that reaches past the known prefix bails — rank-generic bodies spell the ellipsis (a[:, ..., None, :]); a1that aligns against*Tsfrom the wrong side bails for the same reason.matmulneeds one sharedTsand two known trailing dims per unpack side; vectors over*Ts, mixed unpacks, and concrete batches against*Tsbail.
Unions and unpacking¶
- Union-typed array inputs (
(N, K, M) | (N, 1, M)) distribute per member: each member solves with the full solver and the results union. Members pair index-wise (exact for co-derived unions); unrelated unions of different member counts, and dunder ops with unions on both sides (mypy matches those cartesianly), bail out instead of guessing. A union with a Python or NumPy scalar (F64[M] | float,float | int) distributes too when it is the only union among the operands;F64[N] | Nonedistributes innp.average(weights=...)and at calls of user functions (#331). Other optional arguments of NumPy calls still keep the stubs' answer. - A union actual at a call of a user function is checked per member
for the call-site conflict and the
numty-namesbinding report; the return is mypy's own solve of the whole union, not a union of per-member returns. - In a loop body, the joins that grow a size (
concatenate, the stacks,append) do not distribute over a union: mypy stops re-checking the body after a few passes, so a growing accumulator would be cut off at the last pass. Ops that distribute elsewhere (indexing,x[1:]in a loop) have the same cut-off and are not widened either. - Heterogeneous unpacking (
a, b = (f(p) for p in (p1, p2))with different shapes) assigns the whole union to every name — genexp, list comprehension,tuple(...), starred targets, andmap(which dissolves toAny) all behave this way. The body still checks per member; per-name precision needs the tuple display (orpython -m numty_codmod --unpack-displays, which unrolls mapped comprehensions over bare names to the display).
Named sizes and index positions¶
- Different TypeVars (
NvsM) are never a provable conflict, even when provably different at a call site — only same-name TypeVars are unified. Thenumty-namescode, on by default since #327, reports them inside a generic body as a likely mistake, not a proof; with it off they meet silently (see Error codes). - Index positions are tracked through
Index[P, N], which records that an int array's values index size P. A recorded size above the indexed axis errors as[numty-axis]when the overrun is certain (arange(5)on a size-3 axis, anargsortofN+1values on an axis ofN). A possible overrun (searchsortedpositions onN+1overN) and a different named size (positions on M over N) arenumty-namesreports (on by default) and are silent withdisable_error_code = numty-names. Producers record the fact themselves when the values are certainly positions (argmin/argmaxand thenantwins,argsort/argpartitionon one known axis with a 1-d result, 0-basedarange, 1-dflatnonzero, per-membernonzero/where,searchsorted). Everything else stays silent: multi-dim results, records flowing through value-preserving ops (reshape,astype, arithmetic), cross-module uses, ambiguous records (two producers on one line with same-shaped results), unions that disagree, and scalar positions (a-1-for-none sentinel is not tracked). - A size that is a sum of values is not followed.
np.repeat(x, counts)with an array of counts hassum(counts)entries: a plainint. That the counts sum to a named size is the author's to state, with an annotated local or the declared return (per_entry: F64[N] = np.repeat(peak, lengths)); it is not checked. With it, "reduce per run, then spread back per entry" is checked: the line that forgot thenp.repeatassigns a per-run size whereNis declared (#289,tests/fixtures/repeat_counts.py). A runtime check is #17. - Index positions are best-effort by design. A certain overrun errors
by default (
arange(5)on a size-3 axis); a possible one (searchsortedpositions onN+1overN) and a different named size arenumty-namesreports and are silent with that code off, including at a same-module plain call (an actual recorded on N passed where the formal declaresIndexon P,tests/fixtures/bad_index_callsite.py). Records come from theIndex[P, N]annotation (resolved off the enclosing function, so two parameters on onedefline stay distinct) and from producers whose values are certainly positions. Everything else stays silent: multi-dim results, records flowing through value-preserving ops (reshape,astype, arithmetic) or a rebinding (clip,- 1clear the record), cross-module or method calls, keyword arguments, aliases (R = Index[P, N]), ambiguous records (two producers on one line with same-shaped results), unions that disagree, and scalar positions (a-1-for-none sentinel is not tracked) (#285, #286, #287).
Annotations and integer bookkeeping¶
- A test on a shape does not narrow it. In
if scores.shape[1] == 1: return scores.reshape(-1)the reshape is still(k*n,), andassert out.shape[1] == means.shape[0]does not make an unknown?Uthe parameter'sc: mypy has no plugin hook for narrowing on a condition. Write what the branch knows (scores[:, 0]is(n,)), or check at runtime withnarrow(out, witness)ortyping.cast(#17, #306). -
A list holds one item type, so its items share their sizes:
list[F64[N, int]]says every item hasNrows, and per-layer sizes (an MLP'scoefs_, layerimappingH_itoH_{i+1}) have no spelling. A fixed depth can be a tuple of heads (tuple[F64[I, H], F64[H, O]]); a variable depth stayslist[F64[int, int]], and the layer loop is checked for rank and dtype only (#28). -
Bare ints are rejected in alias definitions by mypy itself, before any plugin runs (
Mat34 = F64[3, 4]errors exactly likeX = list[3]does). Spell the sizes withLiteral(Mat34: TypeAlias = F64[Literal[3], Literal[4]], or the PEP 695typeform); uses then check and infer exactly. - Integer bookkeeping on a symbolic dim keeps the dim, like plain
mypy for generic containers (
n = x.shape[0]infersN, son //= 2is an error). Concrete dims widen toint(keeping the value for direct uses likereshape(x.shape[0], 3)), and unknown (?U) dims are plainint. For a loop counter over a symbolic axis, writeint(...)explicitly (n = int(x.shape[0])). In array arithmetic such a size is a Pythonintscalar:X @ X.T / X.shape[0]keeps(N, N)like/ float(X.shape[0])does.