From 8ba2f7f985ccdaa4cf7c857bf273fbd54e882bd0 Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Fri, 15 Mar 2024 18:24:07 +0100
Subject: [PATCH 1/6] Slice items can now be a
 contextlib.AbstractContextManager; deprecated SliceSource.dispose() and
 introduced SliceSource.close().

---
 tests/slice/test_cm.py              | 117 +++++++++++++++++++++++++++-
 tests/slice/test_source.py          |  38 ++++++---
 tests/test_api.py                   |  24 +++++-
 zappend/slice/cm.py                 |  13 +++-
 zappend/slice/source.py             |  33 +++++---
 zappend/slice/sources/memory.py     |   2 +-
 zappend/slice/sources/persistent.py |   3 +-
 zappend/slice/sources/temporary.py  |   4 +-
 8 files changed, 202 insertions(+), 32 deletions(-)

diff --git a/tests/slice/test_cm.py b/tests/slice/test_cm.py
index 84b81eb..4f29343 100644
--- a/tests/slice/test_cm.py
+++ b/tests/slice/test_cm.py
@@ -2,6 +2,7 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
+import contextlib
 import shutil
 import unittest
 import warnings
@@ -13,6 +14,7 @@
 from zappend.fsutil.fileobj import FileObj
 from zappend.slice.cm import SliceSourceContextManager
 from zappend.slice.cm import open_slice_dataset
+from zappend.slice.source import SliceSource
 from zappend.slice.sources.memory import MemorySliceSource
 from zappend.slice.sources.persistent import PersistentSliceSource
 from zappend.slice.sources.temporary import TemporarySliceSource
@@ -23,12 +25,11 @@
 # noinspection PyUnusedLocal
 
 
-# noinspection PyShadowingBuiltins
+# noinspection PyShadowingBuiltins,PyRedeclaration
 class OpenSliceDatasetTest(unittest.TestCase):
     def setUp(self):
         clear_memory_fs()
 
-    # noinspection PyMethodMayBeStatic
     def test_slice_item_is_slice_source(self):
         dataset = make_test_dataset()
         ctx = Context(dict(target_dir="memory://target.zarr"))
@@ -127,7 +128,6 @@ def test_slice_item_is_uri_with_polling_ok(self):
         with slice_cm as slice_ds:
             self.assertIsInstance(slice_ds, xr.Dataset)
 
-    # noinspection PyMethodMayBeStatic
     def test_slice_item_is_uri_with_polling_fail(self):
         slice_dir = FileObj("memory://slice.zarr")
         ctx = Context(
@@ -140,3 +140,114 @@ def test_slice_item_is_uri_with_polling_fail(self):
         with pytest.raises(FileNotFoundError, match=slice_dir.uri):
             with slice_cm:
                 pass
+
+    def test_slice_item_is_context_manager(self):
+        @contextlib.contextmanager
+        def get_dataset(name):
+            uri = f"memory://{name}.zarr"
+            ds = make_test_dataset(uri=uri)
+            try:
+                yield ds
+            finally:
+                ds.close()
+                FileObj(uri).delete(recursive=True)
+
+        ctx = Context(
+            dict(
+                target_dir="memory://target.zarr",
+                slice_source=get_dataset,
+            )
+        )
+        slice_cm = open_slice_dataset(ctx, "bibo")
+        self.assertIsInstance(slice_cm, contextlib.AbstractContextManager)
+        with slice_cm as slice_ds:
+            self.assertIsInstance(slice_ds, xr.Dataset)
+
+    def test_slice_item_is_slice_source(self):
+        class MySliceSource(SliceSource):
+            def __init__(self, name):
+                self.uri = f"memory://{name}.zarr"
+                self.ds = None
+
+            def get_dataset(self):
+                self.ds = make_test_dataset(uri=self.uri)
+                return self.ds
+
+            def close(self):
+                if self.ds is not None:
+                    self.ds.close()
+                FileObj(uri=self.uri).delete(recursive=True)
+
+        ctx = Context(
+            dict(
+                target_dir="memory://target.zarr",
+                slice_source=MySliceSource,
+            )
+        )
+        slice_cm = open_slice_dataset(ctx, "bibo")
+        self.assertIsInstance(slice_cm, SliceSourceContextManager)
+        self.assertIsInstance(slice_cm.slice_source, SliceSource)
+        with slice_cm as slice_ds:
+            self.assertIsInstance(slice_ds, xr.Dataset)
+
+    def test_slice_item_is_deprecated_slice_source(self):
+        class MySliceSource(SliceSource):
+            def __init__(self, name):
+                self.uri = f"memory://{name}.zarr"
+                self.ds = None
+
+            def get_dataset(self):
+                self.ds = make_test_dataset(uri=self.uri)
+                return self.ds
+
+            def dispose(self):
+                if self.ds is not None:
+                    self.ds.close()
+                FileObj(uri=self.uri).delete(recursive=True)
+
+        ctx = Context(
+            dict(
+                target_dir="memory://target.zarr",
+                slice_source=MySliceSource,
+            )
+        )
+        slice_cm = open_slice_dataset(ctx, "bibo")
+        self.assertIsInstance(slice_cm, SliceSourceContextManager)
+        self.assertIsInstance(slice_cm.slice_source, SliceSource)
+        with pytest.warns(expected_warning=DeprecationWarning):
+            with slice_cm as slice_ds:
+                self.assertIsInstance(slice_ds, xr.Dataset)
+
+
+class IsContextManagerTest(unittest.TestCase):
+    """Assert that context managers are identified by isinstance()"""
+
+    def test_context_manager_class(self):
+        @contextlib.contextmanager
+        def my_slice_source(data):
+            ds = xr.Dataset(data)
+            try:
+                yield ds
+            finally:
+                ds.close()
+
+        item = my_slice_source([1, 2, 3])
+        self.assertTrue(isinstance(item, contextlib.AbstractContextManager))
+        self.assertFalse(isinstance(my_slice_source, contextlib.AbstractContextManager))
+
+    def test_context_manager_protocol(self):
+        class MySliceSource:
+            def __enter__(self):
+                return xr.Dataset()
+
+            def __exit__(self, *exc):
+                pass
+
+        item = MySliceSource()
+        self.assertTrue(isinstance(item, contextlib.AbstractContextManager))
+        self.assertFalse(isinstance(MySliceSource, contextlib.AbstractContextManager))
+
+    def test_dataset(self):
+        item = xr.Dataset()
+        self.assertTrue(isinstance(item, contextlib.AbstractContextManager))
+        self.assertFalse(isinstance(xr.Dataset, contextlib.AbstractContextManager))
diff --git a/tests/slice/test_source.py b/tests/slice/test_source.py
index a605b72..adf4fa5 100644
--- a/tests/slice/test_source.py
+++ b/tests/slice/test_source.py
@@ -2,24 +2,19 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
-import shutil
 import unittest
-import warnings
 
 import pytest
 import xarray as xr
 
 from zappend.context import Context
 from zappend.fsutil.fileobj import FileObj
-from zappend.slice.cm import SliceSourceContextManager
-from zappend.slice.cm import open_slice_dataset
-from zappend.slice.source import to_slice_source, SliceSource
+from zappend.slice.source import SliceSource
+from zappend.slice.source import to_slice_source
 from zappend.slice.sources.memory import MemorySliceSource
 from zappend.slice.sources.persistent import PersistentSliceSource
 from zappend.slice.sources.temporary import TemporarySliceSource
 from tests.helpers import clear_memory_fs
-from tests.helpers import make_test_dataset
-from tests.config.test_config import CustomSliceSource
 
 
 # noinspection PyUnusedLocal
@@ -86,7 +81,6 @@ def my_slice_source(arg1, arg2=None, ctx=None):
             return xr.Dataset(attrs=dict(arg1=arg1, arg2=arg2, ctx=ctx))
 
         ctx = make_ctx(slice_source=my_slice_source)
-        arg = xr.Dataset()
         slice_source = to_slice_source(ctx, ([13], {"arg2": True}), 0)
         self.assertIsInstance(slice_source, MemorySliceSource)
         ds = slice_source.get_dataset()
@@ -94,6 +88,26 @@ def my_slice_source(arg1, arg2=None, ctx=None):
         self.assertEqual(True, ds.attrs.get("arg2"))
         self.assertIs(ctx, ds.attrs.get("ctx"))
 
+    def test_slice_item_is_slice_source_context_manager(self):
+        import contextlib
+
+        @contextlib.contextmanager
+        def my_slice_source(ctx, arg1, arg2=None):
+            _ds = xr.Dataset(attrs=dict(arg1=arg1, arg2=arg2, ctx=ctx))
+            try:
+                yield _ds
+            finally:
+                _ds.close()
+
+        ctx = make_ctx(slice_source=my_slice_source)
+        slice_source = to_slice_source(ctx, ([14], {"arg2": "OK"}), 0)
+        self.assertIsInstance(slice_source, contextlib.AbstractContextManager)
+        with slice_source as ds:
+            self.assertIsInstance(ds, xr.Dataset)
+            self.assertEqual(14, ds.attrs.get("arg1"))
+            self.assertEqual("OK", ds.attrs.get("arg2"))
+            self.assertIs(ctx, ds.attrs.get("ctx"))
+
     # noinspection PyMethodMayBeStatic
     def test_raises_if_slice_item_is_int(self):
         ctx = make_ctx(persist_mem_slices=True)
@@ -101,7 +115,9 @@ def test_raises_if_slice_item_is_int(self):
             TypeError,
             match=(
                 "slice_item must have type str, xarray.Dataset,"
-                " zappend.api.FileObj, zappend.api.SliceSource, but was type int"
+                " contextlib.AbstractContextManager,"
+                " zappend.api.FileObj, zappend.api.SliceSource,"
+                " but was type int"
             ),
         ):
             to_slice_source(ctx, 42, 0)
@@ -116,7 +132,9 @@ def hallo():
             TypeError,
             match=(
                 "slice_item must have type str, xarray.Dataset,"
-                " zappend.api.FileObj, zappend.api.SliceSource, but was type function"
+                " contextlib.AbstractContextManager,"
+                " zappend.api.FileObj, zappend.api.SliceSource,"
+                " but was type function"
             ),
         ):
             to_slice_source(ctx, hallo, 0)
diff --git a/tests/test_api.py b/tests/test_api.py
index 610d9d0..b95a07d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -150,7 +150,7 @@ def __init__(self, slice_ds):
             def get_dataset(self) -> xr.Dataset:
                 return self.slice_ds.drop_vars(["tsm"])
 
-            def dispose(self):
+            def close(self):
                 pass
 
         target_dir = "memory://target.zarr"
@@ -168,6 +168,28 @@ def dispose(self):
             ds.attrs,
         )
 
+    def test_some_slices_with_slice_source_cm(self):
+        import contextlib
+
+        @contextlib.contextmanager
+        def drop_tsm(slice_ds: xr.Dataset) -> xr.Dataset:
+            yield slice_ds.drop_vars(["tsm"])
+
+        target_dir = "memory://target.zarr"
+        slices = [make_test_dataset(index=3 * i) for i in range(3)]
+        zappend(slices, target_dir=target_dir, slice_source=drop_tsm)
+        ds = xr.open_zarr(target_dir)
+        self.assertEqual({"time": 9, "y": 50, "x": 100}, ds.sizes)
+        self.assertEqual({"chl"}, set(ds.data_vars))
+        self.assertEqual({"time", "y", "x"}, set(ds.coords))
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "title": "Test 1-3",
+            },
+            ds.attrs,
+        )
+
     def test_some_slices_with_slice_source_func(self):
         def drop_tsm(slice_ds: xr.Dataset) -> xr.Dataset:
             return slice_ds.drop_vars(["tsm"])
diff --git a/zappend/slice/cm.py b/zappend/slice/cm.py
index 0aacef3..c81b96e 100644
--- a/zappend/slice/cm.py
+++ b/zappend/slice/cm.py
@@ -3,7 +3,7 @@
 # https://opensource.org/licenses/MIT.
 
 import contextlib
-from typing import Any
+from typing import Any, ContextManager
 
 import xarray as xr
 
@@ -15,6 +15,8 @@
 class SliceSourceContextManager(contextlib.AbstractContextManager):
     """A context manager that wraps a slice source.
 
+    Internal class, no API.
+
     Args:
         slice_source: The slice source.
     """
@@ -30,7 +32,7 @@ def __enter__(self) -> xr.Dataset:
         return self._slice_source.get_dataset()
 
     def __exit__(self, *exception_args):
-        self._slice_source.dispose()
+        self._slice_source.close()
         self._slice_source = None
 
 
@@ -38,7 +40,7 @@ def open_slice_dataset(
     ctx: Context,
     slice_item: Any,
     slice_index: int = 0,
-) -> SliceSourceContextManager:
+) -> ContextManager[xr.Dataset]:
     """Open the slice source for given slice item `slice_item`.
 
     The intended and only use of the returned slice source is as context
@@ -77,4 +79,7 @@ class derived from `zappend.slice.SliceSource` or a function that returns
         A new slice source instance
     """
     slice_source = to_slice_source(ctx, slice_item, slice_index)
-    return SliceSourceContextManager(slice_source)
+    if isinstance(slice_source, contextlib.AbstractContextManager):
+        return slice_source
+    else:
+        return SliceSourceContextManager(slice_source)
diff --git a/zappend/slice/source.py b/zappend/slice/source.py
index 6d66f10..182c1f5 100644
--- a/zappend/slice/source.py
+++ b/zappend/slice/source.py
@@ -2,8 +2,10 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
+import contextlib
+import warnings
 from abc import abstractmethod, ABC
-from typing import Callable, Type
+from typing import Callable, ContextManager, Type
 
 import xarray as xr
 
@@ -14,11 +16,11 @@
 class SliceSource(ABC):
     """Slice source interface definition.
 
-    A slice source is a disposable source for a slice dataset.
+    A slice source is a closable source for a slice dataset.
 
     A slice source is intended to be implemented by users. An implementation
     must provide the methods [get_dataset()][zappend.slice.abc.SliceSource.get_dataset]
-    and [dispose()][zappend.slice.abc.SliceSource.dispose].
+    and [close()][zappend.slice.abc.SliceSource.dispose].
 
     If your slice source class requires the processing context,
     your class constructor may define a `ctx: Context` as 1st positional
@@ -44,21 +46,31 @@ def get_dataset(self) -> xr.Dataset:
             A slice dataset.
         """
 
-    @abstractmethod
-    def dispose(self):
-        """Dispose this slice source.
+    def close(self):
+        """Close this slice source.
         This should include cleaning up of any temporary resources.
 
         This method is not intended to be called directly
         and is called exactly once for each instance of this class.
         """
+        if hasattr(self, "dispose"):
+            warnings.warn(
+                "The dispose() method of SliceSource has been"
+                " deprecated since zappend 0.6.0,"
+                " please override close() instead.",
+                category=DeprecationWarning,
+            )
+            self.dispose()
 
+    def dispose(self):
+        """Deprecated since version 0.6.0, override [close()][close] instead."""
 
-SliceItem = str | FileObj | xr.Dataset | SliceSource
+
+SliceItem = str | FileObj | xr.Dataset | ContextManager[xr.Dataset] | SliceSource
 """The possible types that can represent a slice dataset."""
 
 SliceCallable = Type[SliceSource] | Callable[[...], SliceItem]
-"""This type that is either a class derived from `SliceSource` 
+"""This type is either a class derived from `SliceSource` 
 or a function that returns a `SliceItem`. Both can be invoked
 with any number of positional or keyword arguments. The processing 
 context, if used, is passed either as 1st positional argument or 
@@ -71,7 +83,7 @@ def to_slice_source(
     ctx: Context,
     slice_item: SliceItem,
     slice_index: int,
-) -> SliceSource:
+) -> SliceSource | ContextManager[xr.Dataset]:
     # prevent cyclic import
     from .sources import MemorySliceSource
     from .sources import PersistentSliceSource
@@ -96,10 +108,13 @@ def to_slice_source(
             return TemporarySliceSource(ctx, slice_item, slice_index)
         else:
             return MemorySliceSource(slice_item, slice_index)
+    if isinstance(slice_item, contextlib.AbstractContextManager):
+        return slice_item
     raise TypeError(
         f"slice_item must have type"
         f" str,"
         f" xarray.Dataset,"
+        f" contextlib.AbstractContextManager,"
         f" zappend.api.FileObj,"
         f" zappend.api.SliceSource,"
         f" but was type {type(slice_item).__name__}"
diff --git a/zappend/slice/sources/memory.py b/zappend/slice/sources/memory.py
index c903e21..14887f1 100644
--- a/zappend/slice/sources/memory.py
+++ b/zappend/slice/sources/memory.py
@@ -24,6 +24,6 @@ def get_dataset(self) -> xr.Dataset:
         logger.info(f"Processing in-memory slice dataset #{self._slice_index}")
         return self._slice_ds
 
-    def dispose(self):
+    def close(self):
         self._slice_ds = None
         logger.info(f"Slice dataset #{self._slice_index} processed")
diff --git a/zappend/slice/sources/persistent.py b/zappend/slice/sources/persistent.py
index 3ac29c5..a0934ef 100644
--- a/zappend/slice/sources/persistent.py
+++ b/zappend/slice/sources/persistent.py
@@ -32,12 +32,11 @@ def get_dataset(self) -> xr.Dataset:
         self._slice_ds = self._wait_for_slice_dataset()
         return self._slice_ds
 
-    def dispose(self):
+    def close(self):
         if self._slice_ds is not None:
             self._slice_ds.close()
             self._slice_ds = None
         logger.info(f"Slice dataset {self._slice_file.uri} closed")
-        super().dispose()
 
     def _wait_for_slice_dataset(self) -> xr.Dataset:
         interval, timeout = self._config.slice_polling
diff --git a/zappend/slice/sources/temporary.py b/zappend/slice/sources/temporary.py
index a751958..d469667 100644
--- a/zappend/slice/sources/temporary.py
+++ b/zappend/slice/sources/temporary.py
@@ -42,7 +42,7 @@ def get_dataset(self) -> xr.Dataset:
         self._temp_slice_ds = xr.open_zarr(temp_slice_store)
         return self._temp_slice_ds
 
-    def dispose(self):
+    def close(self):
         if self._temp_slice_ds is not None:
             self._temp_slice_ds.close()
             self._temp_slice_ds = None
@@ -57,4 +57,4 @@ def dispose(self):
                 )
                 temp_slice_dir.delete(recursive=True)
 
-        super().dispose()
+        super().close()

From 0083efad496570f62141e4887ea0da5003362e4b Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Fri, 15 Mar 2024 18:36:18 +0100
Subject: [PATCH 2/6] Updated version and changes

---
 CHANGES.md          | 12 ++++++++++++
 zappend/__init__.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.md b/CHANGES.md
index 95c76c9..8ddd84a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,15 @@
+## Version 0.7.0 (in development)
+
+* Made writing custom slice sources easier: (#82)
+
+  - Slice items can now be a `contextlib.AbstractContextManager` 
+    so custom slice functions can now be used with
+    [@contextlib.contextmanager](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager).
+  
+  - Introduced `SliceSource.close()` so
+    [contextlib.closing()](https://docs.python.org/3/library/contextlib.html#contextlib.closing)
+    is applicable. Deprecated `SliceSource.dispose()`.
+
 ## Version 0.6.0 (from 2024-03-12)
 
 ### Enhancements
diff --git a/zappend/__init__.py b/zappend/__init__.py
index b419e7a..a939e07 100644
--- a/zappend/__init__.py
+++ b/zappend/__init__.py
@@ -2,4 +2,4 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
-__version__ = "0.6.0"
+__version__ = "0.7.0.dev0"

From 9e6913547d58d2787005994685ca6ca3a88d99d6 Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Sat, 16 Mar 2024 08:39:28 +0100
Subject: [PATCH 3/6] updated config schema and docs

---
 README.md                |  4 ++--
 docs/config.md           |  2 +-
 docs/guide.md            | 47 +++++++++++++++++++++++++++++++++++-----
 docs/index.md            |  4 ++--
 zappend/config/schema.py | 13 ++++++-----
 5 files changed, 55 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md
index ff5bace..1879c8f 100644
--- a/README.md
+++ b/README.md
@@ -61,8 +61,8 @@ The `zappend` tool provides the following features:
   [`zappend`](cli.md) command or from Python. When used from Python using the 
   [`zappend()`](api.md) function, slice datasets can be passed as local file 
   paths, URIs, as datasets of type 
-  [xarray.Dataset](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html), or as custom 
-  [zappend.api.SliceSource](https://bcdev.github.io/zappend/api/#class-slicesource) objects.
+  [xarray.Dataset](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html), or as custom
+  [slice sources](https://bcdev.github.io/zappend/guide/#slice-sources).
 
   
 More about zappend can be found in its 
diff --git a/docs/config.md b/docs/config.md
index 5784ae2..cc5c195 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -193,7 +193,7 @@ Options for the filesystem given by the URI of `target_dir`.
 ## `slice_source`
 
 Type _string_.
-The fully qualified name of a class or function that provides a slice source for each slice item. If a class is given, it must be  derived from `zappend.api.SliceSource`. If a function is given, it must return an instance of  `zappend.api.SliceSource`. Refer to the user guide for more information.
+The fully qualified name of a class or function that receives a slice item as argument(s) and provides the slice dataset. If a class is given, it must be derived from `zappend.api.SliceSource`. If the function is a context manager, it must yield an `xarray.Dataset`. If a plain function is given, it must return any valid slice item type. Refer to the user guide for more information.
 
 ## `slice_engine`
 
diff --git a/docs/guide.md b/docs/guide.md
index 3735eb0..05f4dd6 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -694,10 +694,14 @@ at the cost of additional i/o. It therefore defaults to `false`.
 
 If you need some custom cleanup after a slice has been processed and appended to the 
 target dataset, you can use instances of `zappend.api.SliceSource` as slice items.
-A `SliceSource` class requires you to implement two methods:
+The `SliceSource` methods with special meaning are:
 
-* `get_dataset()` to return the slice dataset of type `xarray.Dataset`, and 
-* `dispose()` to perform any resource cleanup tasks. 
+* `get_dataset()`: a zero-argument method that returns the slice dataset of type 
+  `xarray.Dataset`. You must implement this abstract method. 
+* `close()`: perform any resource cleanup tasks 
+  (in zappend < v0.7, the `close` methods was called `dispose`).
+* `__init__()`: optional constructor that receives any arguments passed to the 
+  slice source.
 
 Here is the template code for your own slice source implementation:
 
@@ -718,7 +722,7 @@ class MySliceSource(SliceSource):
         # You can put any processing here.
         return self.ds
 
-    def dispose(self):
+    def close(self):
         # Write code here that performs cleanup.
         if self.ds is not None:
             self.ds.close()
@@ -795,7 +799,7 @@ class MySliceSource(SliceSource):
         self.ds = xr.open_dataset(self.slice_path)
         return get_mean_slice(self.ds)
 
-    def dispose(self):
+    def close(self):
         if self.ds is not None:
             self.ds.close()
             self.ds = None
@@ -805,6 +809,39 @@ zappend(["slice-1.nc", "slice-2.nc", "slice-3.nc"],
         slice_source=MySliceSource)
 ```
 
+Since zappend 0.7, a slice source can also be written as a Python
+[context manager](https://docs.python.org/3/library/contextlib.html),
+which allows you implementing the `get_dataset()` and `close()` 
+methods in one single function, instead of a class. Here is the above example 
+written as context manager.
+
+```python
+from contextlib import contextmanager
+import numpy as np
+import xarray as xr
+from zappend.api import zappend
+
+# Same as above here
+
+@contextmanager
+def get_slice_dataset(slice_path):
+    # allocate resources here
+    ds = xr.open_dataset(slice_path)
+    mean_ds = get_mean_slice(ds)
+    try:
+        # yield (!) the slice dataset
+        # so it can be appended
+        yield mean_ds
+    finally:
+        # after slice dataset has been appended
+        # release resources here
+        ds.close()
+
+zappend(["slice-1.nc", "slice-2.nc", "slice-3.nc"],
+        target_dir="target.zarr",
+        slice_source=get_slice_dataset)
+```
+
 ## Profiling
 
 Runtime profiling is very important for understanding program runtime behavior 
diff --git a/docs/index.md b/docs/index.md
index b9ef212..b90147d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -39,8 +39,8 @@ The `zappend` tool provides the following features:
   [`zappend`](cli.md) command or from Python. When used from Python using the 
   [`zappend()`](api.md) function, slice datasets can be passed as local file 
   paths, URIs, as datasets of type 
-  [xarray.Dataset](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html), or as custom 
-  [zappend.api.SliceSource](https://bcdev.github.io/zappend/api/#class-slicesource) objects.
+  [xarray.Dataset](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html), or as custom
+  [slice sources](guide#slice-sources).
 
 ## How It Works
 
diff --git a/zappend/config/schema.py b/zappend/config/schema.py
index 16af5d0..ea7acde 100644
--- a/zappend/config/schema.py
+++ b/zappend/config/schema.py
@@ -630,11 +630,14 @@
         },
         slice_source={
             "description": (
-                "The fully qualified name of a class or function that provides"
-                " a slice source for each slice item. If a class is given, it must be "
-                " derived from `zappend.api.SliceSource`."
-                " If a function is given, it must return an instance of "
-                " `zappend.api.SliceSource`."
+                "The fully qualified name of a class or function that receives a"
+                " slice item as argument(s) and provides the slice dataset."
+                " If a class is given,"
+                " it must be derived from `zappend.api.SliceSource`."
+                " If the function is a context manager,"
+                " it must yield an `xarray.Dataset`."
+                " If a plain function is given,"
+                " it must return any valid slice item type."
                 " Refer to the user guide for more information."
             ),
             "type": "string",

From 92ebf58d4558cb71c473a702538129d8223a7727 Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Sat, 16 Mar 2024 10:42:00 +0100
Subject: [PATCH 4/6] fixed docstring

---
 zappend/config/config.py | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/zappend/config/config.py b/zappend/config/config.py
index 971839c..154a8b7 100644
--- a/zappend/config/config.py
+++ b/zappend/config/config.py
@@ -122,9 +122,14 @@ def slice_engine(self) -> str | None:
 
     @property
     def slice_source(self) -> Callable[[...], Any] | None:
-        """The configured slice source type. If given, it must be
-        a callable that returns a value of type `SliceItem` or a class that is
-        derived from `SliceSource` abstract base class.
+        """A class or function that receives a
+        slice item as argument(s) and provides the slice dataset.
+
+        * If a class is given, it must be derived from `zappend.api.SliceSource`.
+        * If the function is a context manager, it must yield an `xarray.Dataset`.
+        * If a plain function is given, it must return any valid slice item type.
+
+        Refer to the user guide for more information.
         """
         return self._slice_source
 

From d23f902a4302448c153f52750acd269aaf78a10a Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Sat, 16 Mar 2024 10:42:08 +0100
Subject: [PATCH 5/6] fixed test

---
 tests/slice/test_cm.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tests/slice/test_cm.py b/tests/slice/test_cm.py
index 4f29343..44fd0db 100644
--- a/tests/slice/test_cm.py
+++ b/tests/slice/test_cm.py
@@ -35,6 +35,7 @@ def test_slice_item_is_slice_source(self):
         ctx = Context(dict(target_dir="memory://target.zarr"))
         slice_item = MemorySliceSource(dataset, 0)
         slice_cm = open_slice_dataset(ctx, slice_item)
+        self.assertIsInstance(slice_cm, SliceSourceContextManager)
         self.assertIs(slice_item, slice_cm.slice_source)
 
     def test_slice_item_is_dataset(self):

From c9228ae73eff3cf59e589405e282d30c924b6444 Mon Sep 17 00:00:00 2001
From: Norman Fomferra <norman.fomferra@brockmann-consult.de>
Date: Sun, 17 Mar 2024 10:09:07 +0100
Subject: [PATCH 6/6] updated docs

---
 docs/guide.md           |  2 +-
 zappend/api.py          | 22 +++++++++++++++-------
 zappend/slice/source.py |  8 +++++---
 3 files changed, 21 insertions(+), 11 deletions(-)

diff --git a/docs/guide.md b/docs/guide.md
index 05f4dd6..a9d8b08 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -699,7 +699,7 @@ The `SliceSource` methods with special meaning are:
 * `get_dataset()`: a zero-argument method that returns the slice dataset of type 
   `xarray.Dataset`. You must implement this abstract method. 
 * `close()`: perform any resource cleanup tasks 
-  (in zappend < v0.7, the `close` methods was called `dispose`).
+  (in zappend < v0.7, the `close` method was called `dispose`).
 * `__init__()`: optional constructor that receives any arguments passed to the 
   slice source.
 
diff --git a/zappend/api.py b/zappend/api.py
index 2f56b89..c3a2fab 100644
--- a/zappend/api.py
+++ b/zappend/api.py
@@ -45,14 +45,22 @@ def zappend(
     This ensures integrity of the  target data cube `target_dir` given
     in `config` or `kwargs`.
 
+    Each slice item in `slices` provides a slice dataset to be appended.
+    The interpretation of a given slice item depends on whether a slice source
+    is configured or not (setting `slice_source`).
+
+    If no slice source is configured, a slice item must be an object of type
+    `str`, `FileObj`, `xarray.Dataset`, or `SliceSource`.
+    If `str` or `FileObj` are used, they are interpreted as local dataset path or
+    dataset URI. If a URI is used, protocol-specific parameters apply, given by the
+    configuration parameter `slice_storage_options`.
+
+    If a slice source is configured, a slice item represents the argument(s) passed
+    to that slice source. Multiple positional arguments can be passed as `list`,
+    multiple keyword arguments as `dict`, and both as a `tuple` of `list` and `dict`.
+
     Args:
-        slices: An iterable that yields slice items. A slice item is
-            either a `str`, `FileObj`, `xarray.Dataset`, `SliceSource`,
-            or represents arguments passed to a configured `slice_source`.
-            If `str` or `FileObj` are used, they are interpreted as
-            local dataset path or dataset URI.
-            If a URI is used, protocol-specific parameters apply, given by
-            configuration parameter `slice_storage_options`.
+        slices: An iterable that yields slice items.
         config: Processor configuration.
             May be a file path or URI, a `dict`, `None`, or a sequence of
             the aforementioned. If a sequence is used, subsequent
diff --git a/zappend/slice/source.py b/zappend/slice/source.py
index 182c1f5..837325e 100644
--- a/zappend/slice/source.py
+++ b/zappend/slice/source.py
@@ -19,8 +19,8 @@ class SliceSource(ABC):
     A slice source is a closable source for a slice dataset.
 
     A slice source is intended to be implemented by users. An implementation
-    must provide the methods [get_dataset()][zappend.slice.abc.SliceSource.get_dataset]
-    and [close()][zappend.slice.abc.SliceSource.dispose].
+    must provide the methods [get_dataset()][zappend.api.SliceSource.get_dataset]
+    and [close()][zappend.api.SliceSource.close].
 
     If your slice source class requires the processing context,
     your class constructor may define a `ctx: Context` as 1st positional
@@ -63,7 +63,9 @@ def close(self):
             self.dispose()
 
     def dispose(self):
-        """Deprecated since version 0.6.0, override [close()][close] instead."""
+        """Deprecated since version 0.6.0, override
+        [close()][zappend.api.SliceSource.close] instead.
+        """
 
 
 SliceItem = str | FileObj | xr.Dataset | ContextManager[xr.Dataset] | SliceSource
