Repository: pytest-dev/pyfakefs (python)

Issue:
Faked `os.makedirs` differs from the built-in `os.makedirs` and `mkdir -p`
**Describe the bug**
> This issue comes with some bonus cursed knowledge.

`os.makedirs` and `mkdir -p` both do a "weird" thing when they create directory trees:

```console 
$ mkdir -p base/foo/../bar
# ls base
bar/  foo/
```

That is:
1. It creates `base/` if absent, then enters `base/`
2. Create `foo/` if absent, then enter `foo/`
3. It creates `../` if absent (it is never absent on "normal" filesystems), then enters `../` (sending it back into `base/`)
4. Then creates `bar/`

(`os.makedirs` does the same thing.)

`pyfakefs`'s `os.makedirs` normalizes the path before it does the above process, meaning the `foo` and `..` parts will be chopped out of the path, and the `foo/` directory will not be created.

**How To Reproduce**
```python
def weird_makedirs(base: Path):
    shutil.rmtree(base, ignore_errors=True)
    b = base / "foo/../bar"
    os.makedirs(b)
    assert (base / "bar").is_dir()
    assert (base / "foo").is_dir()
    assert not (base / "foo/bar").is_dir()

def test_weird_makedirs_realfs():
    weird_makedirs(Path("testdir-tmp"))

def test_weird_makedirs_fakefs(fs):
    weird_makedirs(Path("testdir-tmp"))
```

**Your environment**
Please run the following in the environment where the problem happened and
paste the output.
```bash
$ 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:
Fix behavior of fake os.makedirs
- use a similar implementation as the real fs
- fixes #987

#### 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
- [x] Unit tests passing


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index cf33df4..4a82d9c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -32,6 +32,7 @@ The released versions correspond to PyPI releases.
   (see [#979](../../issues/979))
 * fixed handling of errors on opening files via file descriptor (see [#967](../../issues/967))
 * fixed handling of `umask` - it is now applied by default
+* fixed behavior of `os.makedirs` (see [#987](../../issues/987))
 
 ### Infrastructure
 * replace `undefined` by own minimal implementation to avoid importing it
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index 26be463..58e85e9 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -931,7 +931,27 @@ class FakeOsModule:
         """
         if exist_ok is None:
             exist_ok = False
-        self.filesystem.makedirs(name, mode, exist_ok)
+
+        # copied and adapted from real implementation in os.py (Python 3.12)
+        head, tail = self.filesystem.splitpath(name)
+        if not tail:
+            head, tail = self.filesystem.splitpath(head)
+        if head and tail and not self.filesystem.exists(head):
+            try:
+                self.makedirs(head, exist_ok=exist_ok)
+            except FileExistsError:
+                pass
+            cdir = self.filesystem.cwd
+            if isinstance(tail, bytes):
+                if tail == bytes(cdir, "ASCII"):
+                    return
+            elif tail == cdir:
+                return
+        try:
+            self.mkdir(name, mode)
+        except OSError:
+            if not exist_ok or not self.filesystem.isdir(name):
+                raise
 
     def _path_with_dir_fd(
         self,


Test patch:
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index dc877dc..1c33097 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -1893,6 +1893,14 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         self.assert_raises_os_error(errno.ENOENT, self.os.makedirs, "", exist_ok=False)
         self.assert_raises_os_error(errno.ENOENT, self.os.makedirs, "", exist_ok=True)
 
+    def test_makedirs_with_relative_paths(self):
+        # regression test for #987
+        path = self.make_path("base", "foo", "..", "bar")
+        self.os.makedirs(path)
+        self.assertTrue(self.os.path.isdir(self.make_path("base", "bar")))
+        self.assertTrue(self.os.path.isdir(self.make_path("base", "foo")))
+        self.assertFalse(self.os.path.isdir(self.make_path("base", "foo", "bar")))
+
     # test fsync and fdatasync
     def test_fsync_raises_on_non_int(self):
         with self.assertRaises(TypeError):