Repository: spyder-ide/spyder

Issue:
Symbolic and hard links "broken" when saving files

### 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] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [ ] Reset preferences with ``spyder --reset``
    * [ ] 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

I have a few files which are either symbolic or hard links pointing to other files. Before I updated spyder from ~v.3 to the latest v.4.0.1 (from my anaconda distribution), if I modified and saved the symbolic/hard link, it also modified the other links pointing to the same file accordingly.
However, with v.4.0.1 if I modify a link (symbolic or hard, actually it does not matter), the link is "broken". 

As an example, if I have a file test.py and a symbolic link in another directory also called test.py, if I open the sym link, update its content and then save it, it will no longer be a sym link, but instead a new python file called test.py.
The same applies for hard links, where the link no longer exists after the update (typically stat test.py would show 2 links before saving, and 1 after).

From what I have tried, just opening the file without modifying it does not break the link, only when saving the link no longer exists.

Not sure if it's a bug or the normal behaviour, but it definitely changed between ~v.3 and v.4.1.0.

### What steps reproduce the problem?

1. Create a python file test.py and fill it with some lines of code
2. Make a symbolic/hard link pointing to test.py, (e.g. `ln -s test.py test2.py`)
3. You can check if it worked with `ls -l test2.py` (you should see at the end of the line something like test2.py -> test.py)
4. Open test2.py with spyder, mofidy it and save it
5. Check the sym link again with `ls -l test2.py` (you should  only see test2.py at the end of the line)

### What is the expected output? What do you see instead?

Expected output would be that symbolic or hard links would not be broken, and that modifying one link would modify the content of all the other links pointing to the same file.

## Versions

* Spyder version: 4.0.1
* Python version: 3.7.6
* Qt version: 5.14.1
* PyQt version: 5.14.1
* Operating System name/version: Linux 5.3.0-40-generic (Ubuntu 19.10)

### Dependencies

```
atomicwrites >=1.2.0         :  1.3.0 (OK)
chardet >=2.0.0              :  3.0.4 (OK)
cloudpickle >=0.5.0          :  1.3.0 (OK)
diff_match_patch >=20181111  :  20181111 (OK)
intervaltree                 :  None (OK)
IPython >=4.0                :  7.12.0 (OK)
jedi =0.14.1                 :  0.14.1 (OK)
keyring                      :  None (OK)
nbconvert >=4.0              :  5.6.1 (OK)
numpydoc >=0.6.0             :  0.9.2 (OK)
pexpect >=4.4.0              :  4.8.0 (OK)
pickleshare >=0.4            :  0.7.5 (OK)
psutil >=0.3                 :  5.6.7 (OK)
pygments >=2.0               :  2.5.2 (OK)
pylint >=0.25                :  2.4.4 (OK)
pyls >=0.31.2;<0.32.0        :  0.31.7 (OK)
xdg >=0.26                   :  0.26 (OK)
zmq >=17                     :  18.1.1 (OK)
qdarkstyle >=2.7             :  2.8 (OK)
qtawesome >=0.5.7            :  0.6.1 (OK)
qtconsole >=4.6.0            :  4.6.0 (OK)
qtpy >=1.5.0                 :  1.9.0 (OK)
rtree >=0.8.3                :  0.9.3 (OK)
sphinx >=0.6.6               :  2.4.0 (OK)
spyder_kernels >=1.8.1;<2.0.0:  1.8.1 (OK)
watchdog                     :  None (OK)
cython >=0.21                :  0.29.15 (OK)
matplotlib >=2.0.0           :  3.1.3 (OK)
numpy >=1.7                  :  1.18.1 (OK)
pandas >=0.13.1              :  1.0.1 (OK)
scipy >=0.17.0               :  1.4.1 (OK)
sympy >=0.7.3                :  1.5.1 (OK)
```



Discussion:
@andfoy please take a look at this one
@dalthviz, didn't you look at this before?
@ccordoba12 I'm not sure, I don't remember checking something regarding symlinks, but maybe this is do the use of `atomicwrites` in some way? 

Edit: I was checking and regarding files saving I did a fix but it was to preserve file permission when saving - PR #9550
> Edit: I was checking and regarding files saving I did a fix but it was to preserve file permission when saving - PR #9550

Ok, that was it!

----

@dalthviz, I think the usage of `atomicwrites` makes no possible to use symlinks, so we should forbid opening symlinks in the editor, even if that's a regression with respect to Spyder 3.

So please investigate first the possibility of working with symlinks and `atomicwrites`. If that's not possible, please add a warning message saying that it's not possible to open symlinks anymore to guarantee that files are saved correctly to disk.
Note: Probably we need to resolve symlinks when saving. We can use something like: [`os.readlink`](https://docs.python.org/3/library/os.html#os.readlink), [`os.path.realpath`](https://docs.python.org/3/library/os.path.html#os.path.realpath) or [`pathlib.Path.resolve`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.resolve)

Resolving patch:
diff --git a/spyder/utils/encoding.py b/spyder/utils/encoding.py
--- a/spyder/utils/encoding.py
+++ b/spyder/utils/encoding.py
@@ -16,6 +16,7 @@
 import locale
 import re
 import os
+import os.path as osp
 import sys
 import time
 import errno
@@ -26,9 +27,14 @@
 
 # Local imports
 from spyder.py3compat import (is_string, to_text_string, is_binary_string,
-                              is_unicode)
+                              is_unicode, PY2)
 from spyder.utils.external.binaryornot.check import is_binary
 
+if PY2:
+    import pathlib2 as pathlib
+else:
+    import pathlib
+
 
 PREFERRED_ENCODING = locale.getpreferredencoding()
 
@@ -233,15 +239,25 @@ def write(text, filename, encoding='utf-8', mode='wb'):
     Return (eventually new) encoding
     """
     text, encoding = encode(text, encoding)
+
+    if os.name == 'nt':
+        try:
+            absolute_filename = to_text_string(
+                pathlib.Path(filename).resolve())
+        except (OSError, RuntimeError):
+            absolute_filename = osp.realpath(filename)
+    else:
+        absolute_filename = osp.realpath(filename)
+
     if 'a' in mode:
-        with open(filename, mode) as textfile:
+        with open(absolute_filename, mode) as textfile:
             textfile.write(text)
     else:
         # Based in the solution at untitaker/python-atomicwrites#42.
         # Needed to fix file permissions overwritting.
         # See spyder-ide/spyder#9381.
         try:
-            file_stat = os.stat(filename)
+            file_stat = os.stat(absolute_filename)
             original_mode = file_stat.st_mode
             creation = file_stat.st_atime
         except OSError:  # Change to FileNotFoundError for PY3
@@ -251,20 +267,20 @@ def write(text, filename, encoding='utf-8', mode='wb'):
             original_mode = 0o777 & ~umask
             creation = time.time()
         try:
-            with atomic_write(filename, overwrite=True,
+            with atomic_write(absolute_filename, overwrite=True,
                               mode=mode) as textfile:
                 textfile.write(text)
         except OSError as error:
             # Some filesystems don't support the option to sync directories
             # See untitaker/python-atomicwrites#17
             if error.errno != errno.EINVAL:
-                with open(filename, mode) as textfile:
+                with open(absolute_filename, mode) as textfile:
                     textfile.write(text)
         try:
-            os.chmod(filename, original_mode)
-            file_stat = os.stat(filename)
+            os.chmod(absolute_filename, original_mode)
+            file_stat = os.stat(absolute_filename)
             # Preserve creation timestamps
-            os.utime(filename, (creation, file_stat.st_mtime))
+            os.utime(absolute_filename, (creation, file_stat.st_mtime))
         except OSError:
             # Prevent error when chmod/utime is not allowed
             # See spyder-ide/spyder#11308


Test patch:
diff --git a/spyder/utils/tests/test_encoding.py b/spyder/utils/tests/test_encoding.py
--- a/spyder/utils/tests/test_encoding.py
+++ b/spyder/utils/tests/test_encoding.py
@@ -10,12 +10,46 @@
 import stat
 
 from spyder.utils.encoding import is_text_file, get_coding, write
-from spyder.py3compat import to_text_string
+from spyder.py3compat import to_text_string, PY2
+
+if PY2:
+    import pathlib2 as pathlib
+else:
+    import pathlib
 
 __location__ = os.path.realpath(os.path.join(os.getcwd(),
                                              os.path.dirname(__file__)))
 
 
+def test_symlinks(tmpdir):
+    """
+    Check that modifing symlinks files changes source file and keeps symlinks.
+    """
+    base_dir = tmpdir.mkdir("symlinks")
+    base_file = base_dir.join("symlinks_text.txt")
+    base_file_path = to_text_string(base_file)
+
+    # Write base file
+    write("Some text for symlink", base_file_path)
+
+    # Create symlink
+    symlink_file = pathlib.Path(base_dir.join(
+        'link-to-symlinks_text.txt'))
+    symlink_file.symlink_to(base_file_path)
+    symlink_file_path = to_text_string(symlink_file)
+
+    # Assert the symlink was created
+    assert os.path.islink(symlink_file_path)
+
+    # Write using the symlink
+    encoding = write("New text for symlink", symlink_file_path)
+
+    # Assert symlink is valid and contents of the file
+    assert os.path.islink(symlink_file_path)
+    assert base_file.read_text(encoding) == symlink_file.read_text(encoding)
+    assert symlink_file.read_text(encoding) == 'New text for symlink'
+
+
 def test_permissions(tmpdir):
     """Check that file permissions are preserved."""
     p_file = tmpdir.mkdir("permissions").join("permissions_text.txt")