Repository: tophat/syrupy

Issue:
Custom extension with _dirname outside the pytest session directory results in crash during (detailed) reporting
**Describe the bug**

Currently a custom extension can write snapshots to a new directory by overriding the `_dirname` property, however, this directory has to be a child of pytest's root directory, or else syrupy's reporting crashes with an error like:

```
ValueError: '/tmp/example/__snapshots__/test_file.ambr' is not in the subpath of '/.../path/to/tests' OR one path is relative and the other is absolute.
```

(This is a very niche bug, sorry. Doing this directory hacking is designed to allow building a work-around for https://github.com/pantsbuild/pants/issues/11622.)

**To reproduce**

```python
import pytest

from syrupy.extensions.amber import AmberSnapshotExtension

class CustomSnapshotExtension(AmberSnapshotExtension):
    @property
    def _dirname(self):
        return '/tmp/example/__snapshots__'

@pytest.fixture
def snapshot(snapshot):
    return snapshot.use_extension(CustomSnapshotExtension)

def test_do_it(snapshot):
    assert "one" == snapshot
    assert "two" == snapshot
```

1. run the tests above with `--snapshot-update`
2. comment out the `"two"` line
3. run the tests again with `--snapshot-update` or `--snapshot-details`

Output:

```
$ pytest test_file.py --snapshot-details
======================================================================================================================================================= test session starts ========================================================================================================================================================
platform darwin -- Python 3.10.4, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/huon/projects/tophat/syrupy, configfile: pyproject.toml
plugins: syrupy-1.7.3
collected 1 item                                                                                                                                                                                                                                                                                                                   

test_file.py .                                                                                                                                                                                                                                                                                                               [100%]

----------------------------------------------------------------------------------------------------------------------------------------------------- snapshot report summary ------------------------------------------------------------------------------------------------------------------------------------------------------
1 snapshot passed. 1 snapshot unused.

Traceback (most recent call last):
  File "/Users/huon/projects/tophat/syrupy/test/bin/pytest", line 8, in <module>
    sys.exit(console_main())
...
  File "/Users/huon/projects/tophat/syrupy/src/syrupy/report.py", line 304, in lines
    path_to_file = str(Path(filepath).relative_to(self.base_dir))
  File "/Users/huon/.pyenv/versions/3.10.4/lib/python3.10/pathlib.py", line 816, in relative_to
    raise ValueError("{!r} is not in the subpath of {!r}"
```

**Expected behavior**

Syrupy should behave as normal even with a 'weird' snapshot directory like this.

**Screenshots**

<!-- If applicable, add screenshots to help explain your problem. -->

**Environment (please complete the following information):**

- OS: macOS
- Syrupy Version: 3.0.0
- Python Version: 3.10.4

**Additional context**

Thanks for Syrupy!

Discussion:

Patch:
diff --git a/src/syrupy/report.py b/src/syrupy/report.py
index e642450..09b3991 100644
--- a/src/syrupy/report.py
+++ b/src/syrupy/report.py
@@ -299,7 +299,14 @@ class SnapshotReport:
                 for snapshot_fossil in self.unused:
                     filepath = snapshot_fossil.location
                     snapshots = (snapshot.name for snapshot in snapshot_fossil)
-                    path_to_file = str(Path(filepath).relative_to(self.base_dir))
+
+                    try:
+                        path_to_file = str(Path(filepath).relative_to(self.base_dir))
+                    except ValueError:
+                        # this is just used for display, so better to fallback to
+                        # something vaguely reasonable (the full path) than give up
+                        path_to_file = filepath
+
                     unused_snapshots = ", ".join(map(bold, sorted(snapshots)))
                     yield warning_style(gettext(base_message)) + " {} ({})".format(
                         unused_snapshots, path_to_file


Test patch:
diff --git a/tests/integration/test_snapshot_outside_directory.py b/tests/integration/test_snapshot_outside_directory.py
new file mode 100644
index 0000000..b241c6f
--- /dev/null
+++ b/tests/integration/test_snapshot_outside_directory.py
@@ -0,0 +1,81 @@
+import pytest
+
+
+@pytest.fixture
+def testcases(testdir, tmp_path):
+    dirname = tmp_path.joinpath("__snapshots__")
+    testdir.makeconftest(
+        f"""
+        import pytest
+
+        from syrupy.extensions.amber import AmberSnapshotExtension
+
+        class CustomSnapshotExtension(AmberSnapshotExtension):
+            @property
+            def _dirname(self):
+                return {str(dirname)!r}
+
+        @pytest.fixture
+        def snapshot(snapshot):
+            return snapshot.use_extension(CustomSnapshotExtension)
+        """
+    )
+    return {
+        "zero": (
+            """
+            def test_do_it(snapshot):
+                pass
+            """
+        ),
+        "one": (
+            """
+            def test_do_it(snapshot):
+                assert snapshot == 'passed1'
+            """
+        ),
+        "two": (
+            """
+            def test_do_it(snapshot):
+                assert snapshot == 'passed1'
+                assert snapshot == 'passed2'
+            """
+        ),
+    }
+
+
+@pytest.fixture
+def generate_snapshots(testdir, testcases):
+    testdir.makepyfile(test_file=testcases["two"])
+    result = testdir.runpytest("-v", "--snapshot-update")
+    return result, testdir, testcases
+
+
+def test_generated_snapshots(generate_snapshots):
+    result = generate_snapshots[0]
+    result.stdout.re_match_lines((r"2 snapshots generated\."))
+    assert "snapshots unused" not in result.stdout.str()
+    assert result.ret == 0
+
+
+def test_unmatched_snapshots(generate_snapshots):
+    _, testdir, testcases = generate_snapshots
+    testdir.makepyfile(test_file=testcases["one"])
+    result = testdir.runpytest("-v")
+    result.stdout.re_match_lines((r"1 snapshot passed. 1 snapshot unused\."))
+    assert result.ret == 1
+
+
+def test_updated_snapshots_partial_delete(generate_snapshots):
+    _, testdir, testcases = generate_snapshots
+    testdir.makepyfile(test_file=testcases["one"])
+    result = testdir.runpytest("-v", "--snapshot-update")
+    result.stdout.re_match_lines(r"1 snapshot passed. 1 unused snapshot deleted\.")
+    assert result.ret == 0
+
+
+def test_updated_snapshots_full_delete(generate_snapshots):
+    _, testdir, testcases = generate_snapshots
+    testdir.makepyfile(test_file=testcases["zero"])
+    result = testdir.runpytest("-v", "--snapshot-update")
+    result.stdout.re_match_lines(r"2 unused snapshots deleted\.")
+    assert result.ret == 0