Repository: scipp/scippnexus

Issue:
Support passing an already open h5py file 
This should be possible:
```python
with h5py.File(...) as f:
    with snx.File(f, ...) as nx:
        ...
```

But currently, `snx.File.__init__` simply forwards all arguments to `h5py.File` which does not support this usage pattern.

Discussion:

Solution patch:
diff --git a/src/scippnexus/file.py b/src/scippnexus/file.py
index 930ec516..939cec50 100644
--- a/src/scippnexus/file.py
+++ b/src/scippnexus/file.py
@@ -1,6 +1,8 @@
 # SPDX-License-Identifier: BSD-3-Clause
-# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
 # @author Simon Heybrock
+import io
+import os
 from contextlib import AbstractContextManager
 
 import h5py
@@ -17,6 +19,7 @@
 class File(AbstractContextManager, Group):
     def __init__(
         self,
+        name: str | os.PathLike[str] | io.BytesIO | h5py.Group,
         *args,
         definitions: Definitions | DefaultDefinitionsType = DefaultDefinitions,
         **kwargs,
@@ -27,6 +30,10 @@ def __init__(
 
         Parameters
         ----------
+        name:
+            Specifies the file to open.
+            If this is a :class:`hyp5.File` object, the `:class:`File` will wrap
+            this file handle but will not close it when used as a context manager.
         definitions:
             Mapping of NX_class names to application-specific definitions.
             The default is to use the base definitions as defined in the
@@ -34,15 +41,25 @@ def __init__(
         """
         if definitions is DefaultDefinitions:
             definitions = base_definitions()
-        self._file = h5py.File(*args, **kwargs)
+
+        if isinstance(name, h5py.File | h5py.Group):
+            if args or kwargs:
+                raise TypeError('Cannot provide both h5py.File and other arguments')
+            self._file = name
+            self._manage_file_context = False
+        else:
+            self._file = h5py.File(name, *args, **kwargs)
+            self._manage_file_context = True
         super().__init__(self._file, definitions=definitions)
 
     def __enter__(self):
-        self._file.__enter__()
+        if self._manage_file_context:
+            self._file.__enter__()
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
-        self._file.close()
+        if self._manage_file_context:
+            self._file.close()
 
     def close(self):
         self._file.close()


Test patch:
diff --git a/tests/file_test.py b/tests/file_test.py
new file mode 100644
index 00000000..59e0e9d6
--- /dev/null
+++ b/tests/file_test.py
@@ -0,0 +1,67 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
+
+import io
+from pathlib import Path
+
+import h5py as h5
+import pytest
+
+import scippnexus as snx
+
+
+def is_closed(file: snx.File) -> bool:
+    try:
+        file.create_class('x', 'NXentry')
+    except ValueError as err:
+        return 'Unable to synchronously create group' in err.args[0]
+    return False
+
+
+@pytest.mark.parametrize('path_type', [str, Path])
+def test_load_entry_from_filename(tmp_path, path_type):
+    with h5.File(tmp_path / 'test.nxs', 'w') as f:
+        snx.create_class(f, 'entry', snx.NXentry)
+
+    with snx.File(path_type(tmp_path / 'test.nxs'), 'r') as f:
+        assert f.keys() == {'entry'}
+    assert is_closed(f)
+
+
+def test_load_entry_from_buffer(tmp_path):
+    buffer = io.BytesIO()
+    with h5.File(buffer, 'w') as f:
+        snx.create_class(f, 'entry', snx.NXentry)
+    buffer.seek(0)
+
+    with snx.File(buffer, 'r') as f:
+        assert f.keys() == {'entry'}
+    assert is_closed(f)
+
+
+def test_load_entry_from_h5py_group_root(tmp_path):
+    with h5.File('test.nxs', 'w', driver='core', backing_store=False) as h5_file:
+        snx.create_class(h5_file, 'entry', snx.NXentry)
+        with snx.File(h5_file) as snx_file:
+            assert snx_file.keys() == {'entry'}
+        # Not great, but we don't want to close the h5py file ourselves.
+        assert not is_closed(snx_file)
+
+
+def test_load_entry_from_h5py_group_not_root(tmp_path):
+    with h5.File('test.nxs', 'w', driver='core', backing_store=False) as h5_file:
+        entry = snx.create_class(h5_file, 'entry', snx.NXentry)
+        snx.create_class(entry, 'user', snx.NXuser)
+        with snx.File(h5_file['entry']) as snx_file:
+            assert snx_file.keys() == {'user'}
+        # Not great, but we don't want to close the h5py file ourselves.
+        assert not is_closed(snx_file)
+
+
+def test_file_from_h5py_group_does_not_allow_extra_args(tmp_path):
+    with h5.File('test.nxs', 'w', driver='core', backing_store=False) as h5_file:
+        snx.create_class(h5_file, 'entry', snx.NXentry)
+        with pytest.raises(
+            TypeError, match='Cannot provide both h5py.File and other arguments'
+        ):
+            snx.File(h5_file, 'r')