Repository: spyder-ide/spyder

Issue:
functools.cached_property doesn't behave as expected in the spyder console
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [x] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [x] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [x] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [x] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [x] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [x] Reset preferences with ``spyder --reset``
    * [x] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [x] Tried the other applicable steps from the Troubleshooting Guide
* [x] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below


## Problem Description
`functools.cached_property` caches properties that doesn't change much. Useful for bypassing slow calculations that doesn't change between each call. 
But in Spyder it eagerly calculates as soon a class is initialized, not nice when you want to avoid slow calculations at all cost. 

Turns out when testing that in a standard iPython console that that is not the correct behaviour. It is easier to show with some examples:

### What steps reproduce the problem?
In a plain iPython console I get the expected behaviour:
```python
In [1]: from functools import cached_property
   ...: 
   ...: class DataSet:
   ...:     def __init__(self, sequence_of_numbers):
   ...:         self._data = tuple(sequence_of_numbers)
   ...:         self.set_chunks(self._data[0])
   ...: 
   ...:     @cached_property
   ...:     def shape(self):
   ...:         print("Long calculation time...")
   ...: 
   ...:         out = (len(self._data),)
   ...:         return out
   ...: 
   ...:     def set_chunks(self, chunks):
   ...:         self._chunks = chunks
   ...:         if "shape" in self.__dict__:
   ...:             del self.__dict__["shape"]
   ...:

In [2]: a = DataSet([1, 2, 3])

In [3]: a.shape
Long calculation time...
Out[3]: (3,)

# Very fast now:
In [4]: a.shape
Out[4]: (3,)
```

In spyder:
```python
from functools import cached_property

class DataSet:
    def __init__(self, sequence_of_numbers):
        self._data = tuple(sequence_of_numbers)
        self.set_chunks(self._data[0])

    @cached_property
    def shape(self):
        print("Long calculation time...")

        out = (len(self._data),)
        return out

    def set_chunks(self, chunks):
        self._chunks = chunks
        if "shape" in self.__dict__:
            del self.__dict__["shape"]


a = DataSet([1, 2, 3])
Long calculation time...

# Fast
a.shape
Out[12]: (3,)

# Chunk changes triggers also shape:
a.set_chunks(5)
Long calculation time...

# When it should have been a triggered at this point:
a.shape
Out[12]: (3,)

# Same when deleting from __dict__
del a.__dict__["shape"]
Long calculation time...
```

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: Spyder 4.2.0 
* Python version: Python 3.8.5 64-bit
* Qt version: Qt 5.9.7
* PyQt version: PyQt5 5.9.2
* Operating System name/version: Windows 10 





Discussion:
@Illviljan, thanks for opening this issue. @impact27, do you know what could be the problem here?
My guess would be that the variable explorer accesses the `.shape` property in order to determine the size which is then shown. Try using another name than `shape` or closing the variable explorer.
This issue was found implementing it in dask arrays, changing the name of the property is not realistic. https://github.com/dask/dask/pull/7023
I agree with @jitseniesen:
The variable explorer wants to know the shape. Getting the shape is therefore the explected behaviour as soon as the object is created. Using cached_property avoids recalculating it. How do you think this should be handled?

I guess the solution would be an option to disable the variable explorer if you are using objects whose shape take a long time to know. The problem is that I don't know how many people might need such an option? 
I put a lot effort trying to figure out why cached_property didnt work as expected. 


The variable explorer needs to be transparent about what it is doing. 
For example it can catch anything printed from the methods it uses, and append "triggered by the spyder variable explorer" or something.
How fast is it updating? If the print wasnt instant i might have understood it was something else.

Other thoughts:
When is the calculation triggered? Does this happen when a script is running? 
Is it happening when profiling?
When the prompt returns, the namespace is sent to spyder. Your suggestion of adding a warning when the variable explorer triggers outputting text to the console is good, but I’m not sure how easy it would be to implement. Would you be willing to give it a go?


Seems possible to catch prints using: 
```python
import io
from contextlib import redirect_stdout

def difficult_function():
    print("this is a debug message")
    return 5

f = io.StringIO()
with redirect_stdout(f):
    out = difficult_function()
printed_text = f.getvalue()
```
Inspired by https://stackoverflow.com/questions/16571150/how-to-capture-stdout-output-from-a-python-function-call

So I'm imagining making this a more generic decorator that handles functions and properties and then add that to the functions that does instant calls to the object. 

I have no clue where this is in Spyder though and I don't feel like putting a week or two into figuring it out. 

Resolving patch:
diff --git a/external-deps/spyder-kernels/spyder_kernels/comms/frontendcomm.py b/external-deps/spyder-kernels/spyder_kernels/comms/frontendcomm.py
--- a/external-deps/spyder-kernels/spyder_kernels/comms/frontendcomm.py
+++ b/external-deps/spyder-kernels/spyder_kernels/comms/frontendcomm.py
@@ -229,3 +229,34 @@ def handle_msg(msg):
                 comm._msg_callback(msg)
         comm.handle_msg = handle_msg
         super(FrontendComm, self)._register_comm(comm)
+
+    def _remote_callback(self, call_name, call_args, call_kwargs):
+        """Call the callback function for the remote call."""
+        saved_stdout_write = sys.stdout.write
+        saved_stderr_write = sys.stderr.write
+        sys.stdout.write = WriteWrapper(saved_stdout_write, call_name)
+        sys.stderr.write = WriteWrapper(saved_stderr_write, call_name)
+        try:
+            return super(FrontendComm, self)._remote_callback(
+                call_name, call_args, call_kwargs)
+        finally:
+            sys.stdout.write = saved_stdout_write
+            sys.stderr.write = saved_stderr_write
+
+
+class WriteWrapper():
+    """Wrapper to warn user when text is printed."""
+
+    def __init__(self, write, name):
+        self._write = write
+        self._name = name
+        self._warning_shown = False
+
+    def __call__(self, string):
+        """Print warning once."""
+        if not self._warning_shown:
+            self._warning_shown = True
+            self._write(
+                "\nOutput from spyder call "
+                + repr(self._name) + ":\n")
+        return self._write(string)


Test patch:
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -3836,5 +3836,36 @@ def test_pdb_without_comm(main_window, qtbot):
         main_window.editor.stop_debugging()
 
 
+@pytest.mark.slow
+@flaky(max_runs=3)
+def test_print_comms(main_window, qtbot):
+    """Test warning printed when comms print."""
+    # Write code with a cell to a file
+    code = ("class Test:\n    @property\n    def shape(self):"
+            "\n        print((10,))")
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    qtbot.waitUntil(lambda: shell._prompt_html is not None,
+                    timeout=SHELL_TIMEOUT)
+    control = main_window.ipyconsole.get_focus_widget()
+    nsb = main_window.variableexplorer.get_focus_widget()
+
+    # Create some output from spyder call
+    with qtbot.waitSignal(shell.executed):
+        shell.execute(code)
+
+    assert nsb.editor.source_model.rowCount() == 0
+
+    with qtbot.waitSignal(shell.executed):
+        shell.execute("a = Test()")
+
+    # Wait until the object has appeared in the variable explorer
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 1,
+                    timeout=EVAL_TIMEOUT)
+
+    # Make sure the warning is printed
+    assert ("Output from spyder call 'get_namespace_view':"
+            in control.toPlainText())
+
+
 if __name__ == "__main__":
     pytest.main()