Repository: SciCatProject/scitacean

Issue:
Clone dataset
As a user, it would be efficient to have a clone method on a dataset to create a new one which is the exact copy of the original one, but with the necessary lifecycle properties reset, including pid.

Discussion:

Patch:
diff --git a/docs/release-notes.rst b/docs/release-notes.rst
index c1b4b4b..4cda0d2 100644
--- a/docs/release-notes.rst
+++ b/docs/release-notes.rst
@@ -29,6 +29,28 @@ Release notes
    Stability, Maintainability, and Testing
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
+vYY.0M.MICRO (Unreleased)
+-------------------------
+
+Features
+~~~~~~~~
+
+* Added :func:`scitacean.Dataset.as_new` and :func:`scitacean.Dataset.derive`.
+
+Breaking changes
+~~~~~~~~~~~~~~~~
+
+Bugfixes
+~~~~~~~~
+
+Documentation
+~~~~~~~~~~~~~
+
+Deprecations
+~~~~~~~~~~~~
+
+Stability, Maintainability, and Testing
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 vYY.0M.MICRO (Unreleased)
 -------------------------
diff --git a/src/scitacean/dataset.py b/src/scitacean/dataset.py
index 75575a5..6563539 100644
--- a/src/scitacean/dataset.py
+++ b/src/scitacean/dataset.py
@@ -8,9 +8,9 @@ import dataclasses
 import html
 import itertools
 from copy import deepcopy
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
-from typing import Any, Dict, Generator, List, Literal, Optional, Tuple, Union
+from typing import Any, Dict, Generator, Iterable, List, Literal, Optional, Tuple, Union
 
 from ._dataset_fields import DatasetFields, fields_from_model
 from .datablock import OrigDatablockProxy
@@ -244,6 +244,71 @@ class Dataset(DatasetFields):
             **kwargs,
         )
 
+    def as_new(self) -> Dataset:
+        """Return a new dataset with lifecycle-related fields erased.
+
+        The returned dataset has the same fields as ``self``.
+        But fields that indicate when the dataset was created or
+        by who are set to ``None``.
+        This if, for example, ``created_at``, ``history``, and ``lifecycle``.
+
+        Returns
+        -------
+        :
+            A new dataset without lifecycle-related fields.
+        """
+        return self.replace(
+            _read_only={field.name: None for field in Dataset.fields(read_only=True)},
+            creation_time=datetime.now(tz=timezone.utc),
+            lifecycle=None,
+        )
+
+    def derive(
+        self,
+        *,
+        keep: Iterable[str] = (
+            "contact_email",
+            "instrument_id",
+            "investigator",
+            "orcid_of_owner",
+            "owner",
+            "owner_email",
+            "techniques",
+        ),
+    ) -> Dataset:
+        """Return a new dataset that is derived from self.
+
+        The returned dataset has most fields set to ``None``.
+        But a number of fields can be carried over from ``self``.
+        By default, this assumes that the owner of the derived dataset is the same
+        as the owner of the original.
+        This can be customized with the ``keep`` argument.
+
+        Parameters
+        ----------
+        keep:
+            Fields to copy over to the derived dataset.
+
+        Returns
+        -------
+        :
+            A new derived dataset.
+
+        Raises
+        ------
+        ValueError
+            If ``self`` has no PID.
+            The derived dataset requires a PID in order to link back to ``self``.
+        """
+        if self.pid is None:
+            raise ValueError("Cannot make a derived datasets because self.pid is None.")
+        return Dataset(
+            type=DatasetType.DERIVED,
+            input_datasets=[self.pid],
+            creation_time=datetime.now(tz=timezone.utc),
+            **{name: getattr(self, name) for name in keep},
+        )
+
     def replace_files(self, *files: File) -> Dataset:
         """Return a new dataset with replaced files.
 


Test patch:
diff --git a/src/scitacean/testing/strategies.py b/src/scitacean/testing/strategies.py
index c276b78..7f19137 100644
--- a/src/scitacean/testing/strategies.py
+++ b/src/scitacean/testing/strategies.py
@@ -106,12 +106,18 @@ def datasets(
             if field.name != "type"
         }
 
+    try:
+        pid = make_fixed_arg("pid")
+        del fixed["pid"]
+    except KeyError:
+        pid = st.builds(PID)
+
     kwargs = make_args(read_only=False)
     read_only_arg = st.fixed_dictionaries(make_args(read_only=True))
     return st.builds(
         Dataset,
         type=st.just(dataset_type),
         _read_only=read_only_arg,
-        _pid=st.from_type(PID),
+        _pid=pid,
         **kwargs,
     )
diff --git a/tests/dataset_test.py b/tests/dataset_test.py
index 288cbc1..41bbcd1 100644
--- a/tests/dataset_test.py
+++ b/tests/dataset_test.py
@@ -1,14 +1,14 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 SciCat Project (https://github.com/SciCatProject/scitacean)
 
-from datetime import timedelta
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
 
 import pytest
 from hypothesis import assume, given, settings
 from hypothesis import strategies as st
 
-from scitacean import Dataset, DatasetType, File, model
+from scitacean import PID, Dataset, DatasetType, File, model
 from scitacean.testing import strategies as sst
 
 from .common.files import make_file
@@ -311,3 +311,86 @@ def test_replace_does_not_change_files_with_input_files(initial):
     assert replaced.number_of_files == 1
     assert replaced.size == 6163
     assert list(replaced.files) == list(initial.files)
+
+
+@given(sst.datasets())
+@settings(max_examples=5)
+def test_as_new(initial):
+    new = initial.as_new()
+    assert new.created_at is None
+    assert new.created_by is None
+    assert new.updated_at is None
+    assert new.updated_by is None
+    assert new.history == []
+    assert new.lifecycle is None
+    assert abs(new.creation_time - datetime.now(tz=timezone.utc)) < timedelta(seconds=1)
+
+    assert new.number_of_files == initial.number_of_files
+    assert new.size == initial.size
+    assert new.name == initial.name
+    assert new.input_datasets == initial.input_datasets
+    assert new.data_format == initial.data_format
+
+
+@given(sst.datasets(pid=PID(pid="some-id")))
+@settings(max_examples=5)
+def test_derive_default(initial):
+    derived = initial.derive()
+    assert derived.type == "derived"
+    assert derived.input_datasets == [initial.pid]
+    assert derived.lifecycle is None
+    assert derived.history == []
+
+    assert derived.investigator == initial.investigator
+    assert derived.owner == initial.owner
+    assert derived.instrument_id == initial.instrument_id
+    assert derived.orcid_of_owner == initial.orcid_of_owner
+    assert derived.owner_email == initial.owner_email
+    assert derived.contact_email == initial.contact_email
+    assert derived.techniques == initial.techniques
+
+    assert derived.name is None
+    assert derived.used_software is None
+    assert derived.number_of_files == 0
+    assert derived.number_of_files_archived == 0
+
+
+@given(sst.datasets(pid=PID(pid="some-id")))
+@settings(max_examples=5)
+def test_derive_set_keep(initial):
+    derived = initial.derive(keep=("name", "used_software"))
+    assert derived.type == "derived"
+    assert derived.input_datasets == [initial.pid]
+    assert derived.lifecycle is None
+    assert derived.history == []
+
+    assert derived.name == initial.name
+    assert derived.used_software == initial.used_software
+
+    assert derived.number_of_files == 0
+    assert derived.number_of_files_archived == 0
+
+
+@given(sst.datasets(pid=PID(pid="some-id")))
+@settings(max_examples=5)
+def test_derive_keep_nothing(initial):
+    derived = initial.derive(keep=())
+    assert derived.type == "derived"
+    assert derived.input_datasets == [initial.pid]
+    assert derived.lifecycle is None
+    assert derived.history == []
+
+    assert derived.investigator is None
+    assert derived.owner is None
+    assert derived.instrument_id is None
+    assert derived.name is None
+    assert derived.used_software is None
+    assert derived.number_of_files == 0
+    assert derived.number_of_files_archived == 0
+
+
+@given(sst.datasets(pid=None))
+@settings(max_examples=5)
+def test_derive_requires_pid(initial):
+    with pytest.raises(ValueError):
+        initial.derive()
diff --git a/tests/testing/strategies_test.py b/tests/testing/strategies_test.py
index 682a4b3..7d06af2 100644
--- a/tests/testing/strategies_test.py
+++ b/tests/testing/strategies_test.py
@@ -4,7 +4,7 @@ from dateutil.parser import parse as parse_datetime
 from hypothesis import given, settings
 from hypothesis import strategies as st
 
-from scitacean import DatasetType
+from scitacean import PID, DatasetType
 from scitacean.testing import strategies as sst
 
 
@@ -32,6 +32,8 @@ def test_datasets_can_set_type_to_derived(dset):
         access_groups=["group1", "another-group"],
         source_folder="/the/source/of/truth",
         created_at=parse_datetime("2123-11-29T17:44:56Z"),
+        pid=PID(prefix="prefix", pid="some-id"),
+        description=None,
     )
 )
 def test_datasets_can_fix_fields(dset):
@@ -39,14 +41,19 @@ def test_datasets_can_fix_fields(dset):
     assert dset.access_groups == ["group1", "another-group"]
     assert dset.source_folder == "/the/source/of/truth"
     assert dset.created_at == parse_datetime("2123-11-29T17:44:56Z")
+    assert dset.pid == PID(prefix="prefix", pid="some-id")
+    assert dset.description is None
 
 
 @settings(max_examples=20)
 @given(
     sst.datasets(
-        created_by=st.just("the-creator"), owner=st.sampled_from(("owner1", "owner2"))
+        created_by=st.just("the-creator"),
+        owner=st.sampled_from(("owner1", "owner2")),
+        name=st.text(min_size=2, max_size=5),
     )
 )
 def test_datasets_can_set_strategy_for_fields(dset):
     assert dset.created_by == "the-creator"
     assert dset.owner in ("owner1", "owner2")
+    assert 2 <= len(dset.name) <= 5