Repository: pytest-dev/pyfakefs (python)

Issue:
Faked `os.link` is missing the `follow_symlinks` parameter
**Describe the bug**
Pretty much what it says on the tin.

**How To Reproduce**
```python
def link_nofollow(s: Path, d: Path):
    s.unlink(missing_ok=True)
    d.unlink(missing_ok=True)
    s.symlink_to("nowhere")
    os.link(s, d, follow_symlinks=False)
    assert os.readlink(d) == "nowhere"

def test_realfs():
    link_nofollow(Path("file.txt"), Path("another.txt"))

def test_fakefs(fs):
    link_nofollow(Path("file.txt"), Path("another.txt"))
```

**Your environment**
Please run the following in the environment where the problem happened and
paste the output.
```console
$ python -c "import platform; print(platform.platform())"
Linux-6.7.6-arch1-1-x86_64-with-glibc2.39
$ python -c "import sys; print('Python', sys.version)"
Python 3.11.7 (main, Jan 29 2024, 16:03:57) [GCC 13.2.1 20230801]
$ python -c "from pyfakefs import __version__; print('pyfakefs', __version__)"
pyfakefs 5.4.dev0
$ python -c "import pytest; print('pytest', pytest.__version__)"
pytest 7.2.0
```


Pull request description:
Add missing follow_symlink argument to os.link
- fixes #973

#### Tasks
- [x] Unit tests added that reproduce the issue or prove feature is working
- [x] Fix or feature added
- [x] Entry to release notes added
- [x] Pre-commit CI shows no errors
- [ ] Unit tests passing


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index afed03d..710fadc 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -21,6 +21,7 @@ The released versions correspond to PyPI releases.
 * fixed creation of the temp directory in the fake file system after a filesystem reset
   (see [#965](../../issues/965))
 * fixed handling of `dirfd` in `os.symlink` (see [#968](../../issues/968))
+* add missing `follow_symlink` argument to `os.link` (see [#973](../../issues/973))
 
 ## [Version 5.3.5](https://pypi.python.org/pypi/pyfakefs/5.3.5) (2024-01-30)
 Fixes a regression.
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index 6b6a487..1bf1ea4 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -1185,6 +1185,7 @@ class FakeOsModule:
         *,
         src_dir_fd: Optional[int] = None,
         dst_dir_fd: Optional[int] = None,
+        follow_symlinks: Optional[bool] = None,
     ) -> None:
         """Create a hard link at dst, pointing at src.
 
@@ -1195,14 +1196,21 @@ class FakeOsModule:
                 with `src` being relative to this directory.
             dst_dir_fd: If not `None`, the file descriptor of a directory,
                 with `dst` being relative to this directory.
+            follow_symlinks: (bool) If True (the default), symlinks in the
+                path are traversed.
 
         Raises:
             OSError:  if something already exists at new_path.
             OSError:  if the parent directory doesn't exist.
         """
+        if IS_PYPY and follow_symlinks is not None:
+            raise OSError(errno.EINVAL, "Invalid argument: follow_symlinks")
+        if follow_symlinks is None:
+            follow_symlinks = True
+
         src = self._path_with_dir_fd(src, self.link, src_dir_fd)
         dst = self._path_with_dir_fd(dst, self.link, dst_dir_fd)
-        self.filesystem.link(src, dst)
+        self.filesystem.link(src, dst, follow_symlinks=follow_symlinks)
 
     def fsync(self, fd: int) -> None:
         """Perform fsync for a fake file (in other words, do nothing).


Test patch:
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index 209e51f..708e82f 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -2705,6 +2705,29 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         self.os.unlink(file1_path)
         self.assertEqual(self.os.stat(file2_path).st_nlink, 1)
 
+    @unittest.skipIf(IS_PYPY, "follow_symlinks not supported in PyPi")
+    def test_link_no_follow_symlink(self):
+        self.skip_if_symlink_not_supported()
+        target_path = self.make_path("target_path")
+        self.create_file(target_path, contents="foo")
+        symlink_path = self.make_path("symlink_to_file")
+        self.create_symlink(symlink_path, target_path)
+        link_path = self.make_path("link_to_symlink")
+        self.os.link(symlink_path, link_path, follow_symlinks=False)
+        self.assertTrue(self.os.path.islink(link_path))
+
+    @unittest.skipIf(not IS_PYPY, "follow_symlinks only not supported in PyPi")
+    def test_link_follow_symlink_not_supported_inPypy(self):
+        self.skip_if_symlink_not_supported()
+        target_path = self.make_path("target_path")
+        self.create_file(target_path, contents="foo")
+        symlink_path = self.make_path("symlink_to_file")
+        self.create_symlink(symlink_path, target_path)
+        link_path = self.make_path("link_to_symlink")
+        with self.assertRaises(OSError) as cm:
+            self.os.link(symlink_path, link_path, follow_symlinks=False)
+        self.assertEqual(errno.EINVAL, cm.exception.errno)
+
     def test_nlink_for_directories(self):
         self.skip_real_fs()
         self.create_dir(self.make_path("foo", "bar"))