Repository: scipp/scippnexus

Issue:
Add recursive load helper function
This is not great UX:
```python
with snx.File(filename) as f:
    dg = f[()]
```

- Add a helper function
- Allow setting:
  - definitions
  - root path
  - selection (index ranges)
- Name it `load`

Discussion:

Patch:
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index 16c449f..0722b25 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -113,13 +113,5 @@
    compute_positions
    create_field
    create_class
-```
-
-## Submodules
-
-```{eval-rst}
-.. autosummary::
-   :toctree: ../generated/modules
-   :template: module-template.rst
-   :recursive:
+   load
 ```
diff --git a/docs/conf.py b/docs/conf.py
index dfab278..75af6c0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -60,6 +60,7 @@ autodoc_type_aliases = {
 }
 
 intersphinx_mapping = {
+    'h5py': ('https://docs.h5py.org/en/stable/', None),
     'python': ('https://docs.python.org/3', None),
     'numpy': ('https://numpy.org/doc/stable/', None),
     'scipp': ('https://scipp.github.io/', None),
diff --git a/docs/user-guide/quick-start-guide.ipynb b/docs/user-guide/quick-start-guide.ipynb
index 3951b14..763608a 100644
--- a/docs/user-guide/quick-start-guide.ipynb
+++ b/docs/user-guide/quick-start-guide.ipynb
@@ -46,6 +46,56 @@
     "filename = data.get_path('PG3_4844_event.nxs')"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "dc5c3080-fcca-4118-8fd2-f22f108b1ee6",
+   "metadata": {},
+   "source": [
+    "## Loading files\n",
+    "\n",
+    "Given such a NeXus file, we can load the entire file using [snx.load](../generated/functions/scippnexus.load.rst):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9eac3b09-3b36-464b-821a-4d2617d9fdae",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import scippnexus as snx\n",
+    "\n",
+    "data = snx.load(filename)\n",
+    "data"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8fc979f9-92fc-44d3-b77c-14efc9b2de34",
+   "metadata": {},
+   "source": [
+    "[snx.load](../generated/functions/scippnexus.load.rst) supports selecting part of a file to load:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "05389cfd-2870-413f-a3a7-5ca791d82281",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bank102 = snx.load(filename, root='entry/bank102')\n",
+    "bank102"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e0161b14-8313-4da6-878d-0ed79a3d23a4",
+   "metadata": {},
+   "source": [
+    "This is a simpler and less powerful version of the interface described below."
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "4425ba02-9f53-4783-bdf0-3e966672e3d6",
@@ -53,7 +103,10 @@
    "source": [
     "## Opening files\n",
     "\n",
-    "Given such a NeXus file, we first need to open it.\n",
+    "It is often useful to only load part of a file or inspecting the file structure without loading any data.\n",
+    "ScippNexus provides an interface that is similar to [h5py](https://docs.h5py.org/en/stable/) for this purpose.\n",
+    "\n",
+    "We first need to open the file using [snx.File](../generated/classes/scippnexus.File.rst).\n",
     "Wherever possible this should be done using a context manager as follows:"
    ]
   },
@@ -355,7 +408,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.16"
+   "version": "3.10.14"
   }
  },
  "nbformat": 4,
diff --git a/src/scippnexus/__init__.py b/src/scippnexus/__init__.py
index 4d954ac..9a0bd33 100644
--- a/src/scippnexus/__init__.py
+++ b/src/scippnexus/__init__.py
@@ -20,5 +20,6 @@ from .base import (
 )
 from .field import Attrs, Field
 from .file import File
+from ._load import load
 from .nexus_classes import *
 from .nxtransformations import compute_positions, zip_pixel_offsets
diff --git a/src/scippnexus/_load.py b/src/scippnexus/_load.py
new file mode 100644
index 0000000..2db1912
--- /dev/null
+++ b/src/scippnexus/_load.py
@@ -0,0 +1,119 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+import contextlib
+import io
+from os import PathLike
+
+import h5py as h5
+import scipp as sc
+
+from .base import (
+    DefaultDefinitions,
+    DefaultDefinitionsType,
+    Group,
+    base_definitions,
+)
+from .file import File
+from .typing import Definitions, ScippIndex
+
+
+def load(
+    filename: str | PathLike[str] | io.BytesIO | h5.Group | Group,
+    *,
+    root: str | None = None,
+    select: ScippIndex = (),
+    definitions: Definitions | DefaultDefinitionsType = DefaultDefinitions,
+) -> sc.DataGroup | sc.DataArray | sc.Dataset:
+    """Load a NeXus file.
+
+    This function is a shorthand for opening a file manually.
+    That is
+
+    .. code-block:: python
+
+        loaded = snx.load('path/to/nexus_file.nxs')
+
+    is equivalent to
+
+    .. code-block:: python
+
+        with snx.File('path/to/nexus_file.nxs') as f:
+            loaded = f[()]
+
+    The additional arguments of ``load`` are used as:
+
+    .. code-block:: python
+
+        loaded = snx.load(
+            'path/to/nexus_file.nxs'
+            root='entry/instrument',
+            select={'x': slice(None, 100)},
+            definitions=my_definitions,
+        )
+
+    which corresponds to
+
+    .. code-block:: python
+
+        with snx.File('path/to/nexus_file.nxs', definitions=my_definitions) as f:
+            loaded = f['entry/instrument']['x', :100]
+
+    Parameters
+    ----------
+    filename:
+        One of:
+
+        - A path to a NeXus file.
+        - A file-like object containing a NeXus file.
+        - A :class:`h5py.Group`.
+        - A :class:`scippnexus.Group`.
+    root:
+        The root group in the NeXus file to load.
+        If not provided
+
+        - Everything is loaded under the given group if ``filename`` is a group.
+        - Or the entire file is loaded otherwise.
+    select:
+        Selects a subset of the data to load.
+        Corresponds to the argument passed in brackets when using file objects:
+        ``loaded = group[select]``.
+        See `Loading groups and datasets
+        <../../user-guide/quick-start-guide.html#Loading-groups-and-datasets>`_.
+        Defaults to ``()`` which selects the entire data.
+    definitions:
+        NeXus `application definitions <../../user-guide/application-definitions.rst>`_.
+        Defaults to the ScippNexus base definitions.
+
+    Returns
+    -------
+    :
+        The loaded data.
+    """
+    with _open(filename, definitions=definitions) as group:
+        if root is not None:
+            group = group[root]
+        return group[select]
+
+
+def _open(
+    filename: str | PathLike[str] | io.BytesIO | h5.Group | Group,
+    definitions: Definitions | DefaultDefinitionsType = DefaultDefinitions,
+):
+    if isinstance(filename, h5.Group):
+        return contextlib.nullcontext(
+            Group(
+                filename,
+                definitions=base_definitions()
+                if definitions is DefaultDefinitions
+                else definitions,
+            )
+        )
+    if isinstance(filename, Group):
+        if definitions is not DefaultDefinitions:
+            raise TypeError(
+                'Cannot override application definitions. '
+                'The `definitions` argument must not be used '
+                'When the file is specified as a scippnexus.Group.'
+            )
+        return contextlib.nullcontext(filename)
+    return File(filename, 'r', definitions=definitions)
diff --git a/src/scippnexus/base.py b/src/scippnexus/base.py
index efd70ae..d3fd7be 100644
--- a/src/scippnexus/base.py
+++ b/src/scippnexus/base.py
@@ -550,3 +550,11 @@ def base_definitions() -> dict[str, type]:
     affecting the original.
     """
     return dict(base_definitions_dict)
+
+
+class DefaultDefinitionsType:
+    def __repr__(self) -> str:
+        return "DefaultDefinitions"
+
+
+DefaultDefinitions = DefaultDefinitionsType()
diff --git a/src/scippnexus/file.py b/src/scippnexus/file.py
index 22937b7..930ec51 100644
--- a/src/scippnexus/file.py
+++ b/src/scippnexus/file.py
@@ -1,18 +1,26 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 # @author Simon Heybrock
-from collections.abc import Mapping
 from contextlib import AbstractContextManager
 
 import h5py
 
-from .base import Group, base_definitions
-
-_default_definitions = object()
+from .base import (
+    DefaultDefinitions,
+    DefaultDefinitionsType,
+    Group,
+    base_definitions,
+)
+from .typing import Definitions
 
 
 class File(AbstractContextManager, Group):
-    def __init__(self, *args, definitions: Mapping = _default_definitions, **kwargs):
+    def __init__(
+        self,
+        *args,
+        definitions: Definitions | DefaultDefinitionsType = DefaultDefinitions,
+        **kwargs,
+    ):
         """Context manager for NeXus files, similar to h5py.File.
 
         Arguments other than documented are as in :py:class:`h5py.File`.
@@ -24,7 +32,7 @@ class File(AbstractContextManager, Group):
             The default is to use the base definitions as defined in the
             NeXus standard.
         """
-        if definitions is _default_definitions:
+        if definitions is DefaultDefinitions:
             definitions = base_definitions()
         self._file = h5py.File(*args, **kwargs)
         super().__init__(self._file, definitions=definitions)
diff --git a/src/scippnexus/typing.py b/src/scippnexus/typing.py
index 4ad8c25..339071c 100644
--- a/src/scippnexus/typing.py
+++ b/src/scippnexus/typing.py
@@ -3,7 +3,7 @@
 # @author Simon Heybrock
 from __future__ import annotations
 
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
 from typing import TYPE_CHECKING, Any, Protocol
 
 
@@ -74,3 +74,5 @@ else:
 ScippIndex = (
     ellipsis | int | tuple | slice | tuple[str, int | slice] | dict[str, int | slice]
 )
+
+Definitions = Mapping[str, type]


Test patch:
diff --git a/tests/load_test.py b/tests/load_test.py
new file mode 100644
index 0000000..e6fe16d
--- /dev/null
+++ b/tests/load_test.py
@@ -0,0 +1,212 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+import io
+from pathlib import Path
+
+import h5py as h5
+import pytest
+import scipp as sc
+import scipp.testing
+
+import scippnexus as snx
+
+
+@pytest.fixture()
+def reference_data() -> sc.DataGroup:
+    return sc.DataGroup(
+        entry=sc.DataGroup(
+            log=sc.DataArray(
+                data=sc.arange('time', -0.5, 1.5, 0.5, unit='rad'),
+                coords={
+                    'time': sc.array(
+                        dims=['time'], values=[100, 200, 300, 400], unit='s'
+                    )
+                    + sc.epoch(unit='s')
+                },
+            ),
+            instrument=sc.DataGroup(
+                detector=sc.DataGroup(
+                    data=sc.DataArray(
+                        data=sc.array(
+                            dims=['detector_number'], values=[24, 56, 19], unit='counts'
+                        ),
+                        coords={
+                            'detector_number': sc.array(
+                                dims=['detector_number'], values=[1, 3, 4], unit=None
+                            )
+                        },
+                    )
+                )
+            ),
+        )
+    )
+
+
+@pytest.fixture()
+def nexus_buffer(reference_data) -> io.BytesIO:
+    buffer = io.BytesIO()
+    with snx.File(buffer, 'w') as root:
+        entry = root.create_class('entry', snx.NXentry)
+
+        ref_log = reference_data['entry']['log']
+        log = entry.create_class('log', snx.NXlog)
+        log.create_field('value', ref_log.data)
+        log.create_field('time', ref_log.coords['time'])
+
+        ref_data = reference_data['entry']['instrument']['detector']['data']
+        instrument = entry.create_class('instrument', snx.NXinstrument)
+        detector = instrument.create_class('detector', snx.NXdetector)
+        detector.create_field('detector_number', ref_data.coords['detector_number'])
+        detector.create_field('data', ref_data.data)
+    return buffer
+
+
+def test_load_all_from_buffer(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer)
+    sc.testing.assert_identical(loaded, reference_data)
+
+
+@pytest.mark.parametrize('path_type', [str, Path])
+def test_load_all_from_file(
+    path_type: type,
+    nexus_buffer: io.BytesIO,
+    reference_data: sc.DataGroup,
+    tmp_path: Path,
+) -> None:
+    filename = tmp_path / 'test.nxs'
+    filename.write_bytes(nexus_buffer.getvalue())
+    loaded = snx.load(path_type(filename))
+    sc.testing.assert_identical(loaded, reference_data)
+
+
+def test_load_all_from_snx_group(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with snx.File(nexus_buffer, 'r') as f:
+        loaded = snx.load(f)
+    sc.testing.assert_identical(loaded, reference_data)
+
+
+def test_load_all_from_nested_snx_group(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with snx.File(nexus_buffer, 'r') as f:
+        loaded = snx.load(f['entry'])
+    sc.testing.assert_identical(loaded, reference_data['entry'])
+
+
+def test_load_all_from_h5_group(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with h5.File(nexus_buffer, 'r') as f:
+        loaded = snx.load(f)
+    sc.testing.assert_identical(loaded, reference_data)
+
+
+def test_load_all_from_nested_h5_group(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with h5.File(nexus_buffer, 'r') as f:
+        loaded = snx.load(f['entry'])
+    sc.testing.assert_identical(loaded, reference_data['entry'])
+
+
+def test_load_from_root_single_segment(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer, root='entry')
+    sc.testing.assert_identical(loaded, reference_data['entry'])
+
+
+def test_load_from_root_multiple_segments(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer, root='entry/instrument/detector')
+    sc.testing.assert_identical(
+        loaded, reference_data['entry']['instrument']['detector']
+    )
+
+
+def test_load_nonexistent_root(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with pytest.raises(KeyError):
+        snx.load(nexus_buffer, root='entry/does-not-exist')
+
+
+def test_load_select_index_range(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer, select={'time': slice(1, None)})
+    sc.testing.assert_identical(loaded, reference_data['time', 1:])
+
+
+def test_load_select_index(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer, select={'time': 2})
+    sc.testing.assert_identical(loaded, reference_data['time', 2])
+
+
+def test_load_from_root_select_index_range(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(
+        nexus_buffer,
+        root='entry/instrument',
+        select={'detector_number': slice(None, 2)},
+    )
+    sc.testing.assert_identical(
+        loaded, reference_data['entry']['instrument']['detector_number', :2]
+    )
+
+
+def test_load_select_unknown_dim(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    with pytest.raises(sc.DimensionError):
+        snx.load(nexus_buffer, select={'y': 3})
+
+
+class NXdetectorTimes10(snx.NXdetector):
+    def assemble(self, dg: sc.DataGroup) -> sc.DataGroup:
+        return 10 * super().assemble(dg)
+
+
+def test_load_from_buffer_with_explicit_base_definitions(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    loaded = snx.load(nexus_buffer, definitions=snx.base_definitions())
+    sc.testing.assert_identical(loaded, reference_data)
+
+
+def test_load_from_buffer_with_custom_definitions(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    definitions = {**snx.base_definitions(), 'NXdetector': NXdetectorTimes10}
+    loaded = snx.load(nexus_buffer, definitions=definitions)
+    expected = reference_data.copy()
+    expected['entry']['instrument']['detector']['data'] *= 10
+    sc.testing.assert_identical(loaded, expected)
+
+
+def test_load_from_h5_group_with_custom_definitions(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    definitions = {**snx.base_definitions(), 'NXdetector': NXdetectorTimes10}
+    with h5.File(nexus_buffer, 'r') as f:
+        loaded = snx.load(f, definitions=definitions)
+    expected = reference_data.copy()
+    expected['entry']['instrument']['detector']['data'] *= 10
+    sc.testing.assert_identical(loaded, expected)
+
+
+def test_load_from_snx_group_rejects_new_definitions(
+    nexus_buffer: io.BytesIO, reference_data: sc.DataGroup
+) -> None:
+    definitions = {**snx.base_definitions(), 'NXdetector': NXdetectorTimes10}
+    with pytest.raises(TypeError, match='Cannot override application definitions'):
+        with snx.File(nexus_buffer, 'r') as f:
+            snx.load(f, definitions=definitions)