New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve type inference for user code #25103
base: master
Are you sure you want to change the base?
Conversation
✅ Hi, I am the SymPy bot. I'm here to help you write a release notes entry. Please read the guide on how to write release notes. Your release notes are in good order. Here is what the release notes will look like:
This will be added to https://github.com/sympy/sympy/wiki/Release-Notes-for-1.13. Click here to see the pull request description that was parsed.
|
I'm not a fan of this. If I understand the code correctly, it currently isn't a backwards compatibility break, in that things that subclass Function will also still work. But the temptation is certainly there now to do that. It also in general just feels like the sort of change that just adds extra complexity for no reason other than to satisfy mypy. Is there way to make mypy default to thinking that Function subclasses are actually Function, so that the only thing that wouldn't work is UndefinedFunctions? |
Specifically does just inverting the |
To be fair, I do think the overloading of Function is a bad design, and would have preferred if it were avoided. I would be a little happier with refactoring here if it were to actually go all the way with something better (e.g., #4787). |
Firstly we are talking about pyright here rather than mypy. If you want to see how this works then I suggest trying out vscode with the standard Python language plugins. As for the suggestion of inverting the f = Function('f')
e = f(1) Here any type analysis tool needs to understand that
Agreed. A lot of the complexity here comes from two things:
Both of those are design flaws. Neither is fixable without breaking compatibility though. |
Yes I know that, but it will be significantly fewer errors this way than from not understanding Function subclasses. |
Alternatively, can we just tell it that |
Benchmark results from GitHub Actions Lower numbers are good, higher numbers are bad. A ratio less than 1 Significantly changed benchmark results (PR vs master) Significantly changed benchmark results (master vs previous release) | Change | Before [a00718ba] | After [bbc54928] | Ratio | Benchmark (Parameter) |
|----------|----------------------|---------------------|---------|----------------------------------------------------------------------|
| - | 69.5±2ms | 45.2±0.5ms | 0.65 | integrate.TimeIntegrationRisch02.time_doit(10) |
| - | 69.6±1ms | 44.2±0.3ms | 0.63 | integrate.TimeIntegrationRisch02.time_doit_risch(10) |
| + | 17.8±0.4μs | 30.5±0.3μs | 1.71 | integrate.TimeIntegrationRisch03.time_doit(1) |
| - | 5.42±0.03ms | 2.88±0.02ms | 0.53 | logic.LogicSuite.time_load_file |
| - | 72.6±0.3ms | 29.1±0.3ms | 0.4 | polys.TimeGCD_GaussInt.time_op(1, 'dense') |
| - | 26.0±0.2ms | 17.3±0.08ms | 0.67 | polys.TimeGCD_GaussInt.time_op(1, 'expr') |
| - | 73.9±0.4ms | 29.0±0.3ms | 0.39 | polys.TimeGCD_GaussInt.time_op(1, 'sparse') |
| - | 254±1ms | 127±0.7ms | 0.5 | polys.TimeGCD_GaussInt.time_op(2, 'dense') |
| - | 257±2ms | 126±0.3ms | 0.49 | polys.TimeGCD_GaussInt.time_op(2, 'sparse') |
| - | 652±2ms | 371±1ms | 0.57 | polys.TimeGCD_GaussInt.time_op(3, 'dense') |
| - | 655±5ms | 373±2ms | 0.57 | polys.TimeGCD_GaussInt.time_op(3, 'sparse') |
| - | 492±2μs | 288±2μs | 0.59 | polys.TimeGCD_LinearDenseQuadraticGCD.time_op(1, 'dense') |
| - | 1.77±0.01ms | 1.05±0.01ms | 0.6 | polys.TimeGCD_LinearDenseQuadraticGCD.time_op(2, 'dense') |
| - | 5.81±0.02ms | 3.10±0.02ms | 0.53 | polys.TimeGCD_LinearDenseQuadraticGCD.time_op(3, 'dense') |
| - | 444±4μs | 233±1μs | 0.52 | polys.TimeGCD_QuadraticNonMonicGCD.time_op(1, 'dense') |
| - | 1.48±0.02ms | 678±7μs | 0.46 | polys.TimeGCD_QuadraticNonMonicGCD.time_op(2, 'dense') |
| - | 4.89±0.03ms | 1.67±0.01ms | 0.34 | polys.TimeGCD_QuadraticNonMonicGCD.time_op(3, 'dense') |
| - | 375±3μs | 210±1μs | 0.56 | polys.TimeGCD_SparseGCDHighDegree.time_op(1, 'dense') |
| - | 2.44±0.01ms | 1.25±0.01ms | 0.51 | polys.TimeGCD_SparseGCDHighDegree.time_op(3, 'dense') |
| - | 10.1±0.03ms | 4.45±0.02ms | 0.44 | polys.TimeGCD_SparseGCDHighDegree.time_op(5, 'dense') |
| - | 359±2μs | 169±0.7μs | 0.47 | polys.TimeGCD_SparseNonMonicQuadratic.time_op(1, 'dense') |
| - | 2.53±0.03ms | 907±5μs | 0.36 | polys.TimeGCD_SparseNonMonicQuadratic.time_op(3, 'dense') |
| - | 9.49±0.09ms | 2.66±0.01ms | 0.28 | polys.TimeGCD_SparseNonMonicQuadratic.time_op(5, 'dense') |
| - | 1.03±0ms | 426±4μs | 0.41 | polys.TimePREM_LinearDenseQuadraticGCD.time_op(3, 'dense') |
| - | 1.72±0.01ms | 505±2μs | 0.29 | polys.TimePREM_LinearDenseQuadraticGCD.time_op(3, 'sparse') |
| - | 6.02±0.04ms | 1.83±0.01ms | 0.3 | polys.TimePREM_LinearDenseQuadraticGCD.time_op(5, 'dense') |
| - | 8.49±0.05ms | 1.50±0.01ms | 0.18 | polys.TimePREM_LinearDenseQuadraticGCD.time_op(5, 'sparse') |
| - | 286±2μs | 66.0±0.5μs | 0.23 | polys.TimePREM_QuadraticNonMonicGCD.time_op(1, 'sparse') |
| - | 3.40±0.05ms | 395±4μs | 0.12 | polys.TimePREM_QuadraticNonMonicGCD.time_op(3, 'dense') |
| - | 3.99±0.03ms | 278±2μs | 0.07 | polys.TimePREM_QuadraticNonMonicGCD.time_op(3, 'sparse') |
| - | 7.08±0.08ms | 1.28±0.01ms | 0.18 | polys.TimePREM_QuadraticNonMonicGCD.time_op(5, 'dense') |
| - | 8.73±0.06ms | 836±7μs | 0.1 | polys.TimePREM_QuadraticNonMonicGCD.time_op(5, 'sparse') |
| - | 5.05±0.02ms | 3.02±0.01ms | 0.6 | polys.TimeSUBRESULTANTS_LinearDenseQuadraticGCD.time_op(2, 'sparse') |
| - | 12.2±0.08ms | 6.61±0.03ms | 0.54 | polys.TimeSUBRESULTANTS_LinearDenseQuadraticGCD.time_op(3, 'dense') |
| - | 22.3±0.1ms | 9.12±0.03ms | 0.41 | polys.TimeSUBRESULTANTS_LinearDenseQuadraticGCD.time_op(3, 'sparse') |
| - | 5.24±0.03ms | 867±2μs | 0.17 | polys.TimeSUBRESULTANTS_QuadraticNonMonicGCD.time_op(1, 'sparse') |
| - | 12.6±0.02ms | 7.05±0.04ms | 0.56 | polys.TimeSUBRESULTANTS_QuadraticNonMonicGCD.time_op(2, 'sparse') |
| - | 102±0.8ms | 25.7±0.2ms | 0.25 | polys.TimeSUBRESULTANTS_QuadraticNonMonicGCD.time_op(3, 'dense') |
| - | 168±0.4ms | 54.8±0.2ms | 0.33 | polys.TimeSUBRESULTANTS_QuadraticNonMonicGCD.time_op(3, 'sparse') |
| - | 174±0.9μs | 112±0.7μs | 0.64 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(1, 'dense') |
| - | 358±1μs | 218±3μs | 0.61 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(1, 'sparse') |
| - | 4.27±0.05ms | 856±10μs | 0.2 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(3, 'dense') |
| - | 5.19±0.05ms | 383±1μs | 0.07 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(3, 'sparse') |
| - | 19.9±0.06ms | 2.82±0.01ms | 0.14 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(5, 'dense') |
| - | 22.8±0.06ms | 636±7μs | 0.03 | polys.TimeSUBRESULTANTS_SparseGCDHighDegree.time_op(5, 'sparse') |
| - | 479±1μs | 134±0.8μs | 0.28 | polys.TimeSUBRESULTANTS_SparseNonMonicQuadratic.time_op(1, 'sparse') |
| - | 4.74±0.04ms | 631±2μs | 0.13 | polys.TimeSUBRESULTANTS_SparseNonMonicQuadratic.time_op(3, 'dense') |
| - | 5.24±0.03ms | 141±4μs | 0.03 | polys.TimeSUBRESULTANTS_SparseNonMonicQuadratic.time_op(3, 'sparse') |
| - | 13.1±0.2ms | 1.31±0.01ms | 0.1 | polys.TimeSUBRESULTANTS_SparseNonMonicQuadratic.time_op(5, 'dense') |
| - | 14.0±0.1ms | 141±0.8μs | 0.01 | polys.TimeSUBRESULTANTS_SparseNonMonicQuadratic.time_op(5, 'sparse') |
| - | 133±2μs | 75.4±0.6μs | 0.57 | solve.TimeMatrixOperations.time_rref(3, 0) |
| - | 249±0.5μs | 89.1±0.4μs | 0.36 | solve.TimeMatrixOperations.time_rref(4, 0) |
| - | 24.3±0.1ms | 11.5±0.04ms | 0.47 | solve.TimeSolveLinSys189x49.time_solve_lin_sys |
| - | 28.7±0.2ms | 15.5±0.1ms | 0.54 | solve.TimeSparseSystem.time_linsolve_Aaug(20) |
| - | 56.3±0.6ms | 25.2±0.2ms | 0.45 | solve.TimeSparseSystem.time_linsolve_Aaug(30) |
| - | 28.4±0.2ms | 15.4±0.1ms | 0.54 | solve.TimeSparseSystem.time_linsolve_Ab(20) |
| - | 55.7±0.4ms | 24.8±0.3ms | 0.45 | solve.TimeSparseSystem.time_linsolve_Ab(30) |
Full benchmark results can be found as artifacts in GitHub Actions |
It is possible to tell pyright that It is awkward to make this work but all of the reasons it is awkward are directly connected to the sorts of thing that I already thought were poor design decisions regardless of any type checking tool. |
And really the problem here is the
Making Function subclasses like
Yes, but making a new subclass that has to be used everywhere is just trading one awkwardness for another. The way I see it, we can either
Option 1 is very simple. It doesn't require changes throughout the codebase, and doesn't introduce (or suggest) any backwards incompatibilities. The downside is that some type checks are still wrong, but it's still a significant improvement over the current status quo. And even after option 2 there are still thousands of other type errors from pyright. Option 2 requires changing things throughout the whole codebase. It's maybe not technically a compatibility break, but it looks like one, and could easily become one in the future if people aren't careful. For instance, the custom functions guide now becomes wrong: "this guide serves both as a guide to end users who want to define their own custom functions and to SymPy developers wishing to extend the functions included with SymPy." It would need to be updated to say "Functions defined inside of SymPy should use |
Also, I'm pretty ignorant of type stuff, but can this be solved with overloads? If |
You might be ignorant of typing but I'm pretty sure you know this: In [1]: cos('1')
Out[1]: cos(1) |
That's not supposed to happen and we should get rid of it. That's why I said it wouldn't be able to catch that error, but it should work for correct code. If the type checker breaks down in that one corner case, that seems like a much better alternative to having it not work on any undefined functions. |
It's a very simple change though. It just changes the base class to be one whose We have two uses of
If I was to make a hypothetical grand redesign of how all of this works I would keep the first and break the second. Users use |
Making all expression heads or even just all Function subclasses objects is a much harder break than just making Function('f') an object. It's a good idea but it will require a significant effort to be able to do it. It will most likely require some nasty meta-programming to keep some level of backwards compatibility (which would itself break all type checkers). Either that or we make a clean break in SymPy 2.0 that requires all custom SymPy user code to be updated. |
We could possibly use overload to say that if the argument is a string then |
u = [a.name for a in args if isinstance(a, UndefinedFunction)] | ||
if u: | ||
raise TypeError('Invalid argument: expecting an expression, not UndefinedFunction%s: %s' % ( | ||
's'*(len(u) > 1), ', '.join(u))) | ||
obj = super().__new__(cls, *args, **options) | ||
obj: Expr = super().__new__(cls, *args, **options) # type: ignore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe _new_
should be used directly from here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type: ignore
here is because mypy rejects having a cls.__new__
method whose return hint does not imply that it returns an instance of cls
:
from __future__ import annotations
class A:
def __new__(cls) -> A:
return super().__new__(cls)
class B(A):
def __new__(cls) -> A:
return super().__new__(cls)
reveal_type(B())
Here mypy rejects this although pyright accepts it:
$ mypy q.py
q.py:8: error: Incompatible return type for "__new__" (returns "A", but must return a subtype of "B") [misc]
q.py:11: note: Revealed type is "q.B"
Found 1 error in 1 file (checked 1 source file)
$ pyright q.py
q.py:11:13 - information: Type of "B()" is "A"
0 errors, 0 warnings, 1 information
I could change this to say that the return type is AppliedUndef
and then mypy would not complain. I am not sure if this should really be considered a bug in mypy though.
The return type Expr
was needed for Function.__new__
because you can have e.g. sin(pi) -> 0
so we have no guarantee that sin(obj)
is of type sin
but it should always be of type Expr
. With AppliedUndef
there is no evaluation though so it would be accurate to say that it returns AppliedUndef
. I'm just not sure if that is more useful to users than Expr
(which is not incorrect since AppliedUndef
is a subclass of Expr
).
It is possible to make this work and to get pyright to understand it like this: class Function:
@overload
def __new__(cls, *args: str, **options) -> Type[AppliedUndef]:
...
@overload
def __new__(cls, *args: int | float | Expr, **options) -> Expr:
...
@cacheit
def __new__(cls, *args, **options) -> Type[AppliedUndef] | Expr:
# body of __new__ Then pyright understands what is happening but mypy rejects this:
Those can be ignored with
|
I may suggest the other way around, to split the class factory part, and the object constructor part from the |
We would still need to support the existing |
I opened a mypy issue (python/mypy#15182) but I don't think that there is any way to make this work for mypy without adding |
I thought that |
Getting type hints working with SymPy would be great so I really like the direction of this PR! I don't know enough about the implementation details around @asmeurer wrote:
I opened gh-25145 to discuss some ramifications around a future 2.0. |
Currently this has merge conflicts but I think that it would be good to get this in. There are various alternative suggestions above but they are mostly irrelevant to the main point: without substantial changes to the codebase or breaking compatibilty the approach here is the best way to make accurate type hints for common usage of sympy. The downside is that it needs an extra class that doesn't really do anything but at the same time that class doesn't do anything so it is not really a significant problem. Various people seem to object to anything related to typing on general principle rather than because it has any significant downsides for sympy. The number one usage of type hints is just editor autocompletion which is something that many Python programmers now come to expect. Not supporting basic type inference for editors will make many people dislike using sympy. I think that in future it would be better if interfaces were designed in a way that is simpler from a typing perspective. Many of the things that type checkers struggle with in the sympy codebase are also things that are problematic for other reasons as well. The point of this PR is not to change those things but just to find the minimal way to get type checkers to understand the code as it is. Regardless of anything happening in the future like sympy 2.0 or a new way to define functions like |
I would vote on going this I agree that I am also scared by lots of red underlines on SymPy codebase, that editors gives. And I also agree that avoiding type unhappy code at the first place, just gives more definite and concise code, |
768af51
to
7a25936
Compare
3c914ee
to
1951fb8
Compare
…mpy] was a mistake to add it before adding the corresponding type hints." -- sympy/sympy#22337 . So we disable it on sympy as well. Check also this for progress in adding proper typehinting to sympy: sympy/sympy#25103
1951fb8
to
ee7d2b2
Compare
References to other Issues or PRs
See also long discussion in gh-17945
Brief description of what is fixed or changed
Adds type hints for various attributes and methods of Basic and Expr. Adds a new
DefinedFunction
class as a subclass ofFunction
that should be the base class for defined functions likesin
,cos
etc.These changes are enough for simple end user code involving sympy expressions to be statically analysable so that code completion and editor warnings can work with e.g. the pyright language server that is often used with vscode and other editors (I use pyright from within vim myself now).
It is also possible to use pyright as a type checker e.g.:
(You need to have node and npm installed for this to work.)
Running pyright like this over the codebase takes about 5 minutes on this computer and for master shows:
With this PR it is:
So this removes around half of all of the "errors" in the codebase and pretty much all the warnings. Most of the other errors are probably not that hard to improve with some type declarations but each case requires a bit of analysis and I thought I would stop here before making the diff any larger.
Other comments
It is increasingly common that users of sympy will use an editor like vscode that performs analysis of the code either through type inference or using type hints using a langauge server like pyright. Personally I don't use vscode but I do have the pyright language server running inside vim. The sympy codebase does too many strange things for type inference to work without some type hints. What that means is that if you have pyright running in your editor then just opening a file that uses sympy will cause pyright to consume huge amounts of RAM and CPU while it tries to analyse the sympy codebase. Even then it fails and so the result looks something like this:
In other words ordinary code using sympy shows up as being full of errors. The errors are because type inference fails even for basic things like
cos(1)
. It is even worse if you open up a sympy test suite file e.g.test_manual.py
:The number one thing that confuses pyright is
Function.__new__
and I have looked at how to make accurate type hints forFunction.__new__
but it is impossible because it basically works like this:What this
__new__
method does is that it returns two completely different types of object. More confusinglyFunction.__new__
returns a completely different type of object from anyFunctionSubclass.__new__
method. There is just no way to type a method such that it returns a different tpye when called from a subclass.The only way that I could make sense of this was to introduce a new class:
Most of the lines changed in the diff are just changing many classes to subclass
DefinedFunction
instead ofFunction
.With these changes pyright understands the types of sympy expressions so that it does not report errors and can handle autocompletion etc:
Obviously this kind of completion/help only works if the language server can infer something about the types of variables like
e2
in the code.Release Notes