diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 62c41db72e668cd555a1bcac9151c6b522fe5791..e43223568252b2e7a1504610692fe20dc9d78348 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -121,27 +121,21 @@ unittest_py3.11: - python3 -c "import sys; assert sys.version.startswith('3.11')" - tox -unittest_py3.8: +unittest_py3.9: tags: [cached-dind] stage: test - image: python:3.8 + image: python:3.9 script: &python_test_script # install dependencies - pip install pytest pytest-cov # TODO: Use f-branch logic here - pip install git+https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git@dev - pip install git+https://gitlab.indiscale.com/caosdb/src/caosdb-advanced-user-tools.git@dev - - pip install .[h5-crawler,spss] + - pip install .[h5-crawler,spss,rocrate] # actual test - caosdb-crawler --help - pytest --cov=caosdb -vv ./unittests -unittest_py3.9: - tags: [cached-dind] - stage: test - image: python:3.9 - script: *python_test_script - unittest_py3.10: tags: [cached-dind] stage: test diff --git a/CHANGELOG.md b/CHANGELOG.md index 923e941960d5725674240a8196e63e01a0241c1d..3b55a8fedf6b8ffc8907728fae4fa96da07e810d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unit: m ``` - Support for Python 3.13 +- ROCrateConverter, ELNFileConverter and ROCrateEntityConverter for crawling ROCrate and .eln files - `max_log_level` parameter to `logging.configure_server_side_logging` to control the server-side debuglog's verboosity, and an optional `sss_max_log_level` parameter to `crawler_main` to control the SSS @@ -42,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### +* Support for Python 3.8 (end of life) + ### Fixed ### - Added better error message for some cases of broken converter and diff --git a/setup.cfg b/setup.cfg index 558599013f3556a41481305ba587e3947a403d63..4e2056bdb23da2ba734a1aeda60cfc5e6a6f3e64 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,7 @@ classifiers = package_dir = = src packages = find: -python_requires = >=3.8 +python_requires = >=3.9 install_requires = caosadvancedtools >= 0.7.0 importlib-resources @@ -49,3 +49,5 @@ h5-crawler = numpy spss = pandas[spss] +rocrate = + rocrate @ git+https://github.com/salexan2001/ro-crate-py.git@f-automatic-dummy-ids diff --git a/src/caoscrawler/converters/__init__.py b/src/caoscrawler/converters/__init__.py index 540a4cfca9ff19248baab2bc0fe8d10987d4bd1f..70fca6c44c90a3bbb44bd05c34e51cafae91a229 100644 --- a/src/caoscrawler/converters/__init__.py +++ b/src/caoscrawler/converters/__init__.py @@ -30,3 +30,18 @@ except ImportError as err: SPSSConverter: type = utils.MissingImport( name="SPSSConverter", hint="Try installing with the `spss` extra option.", err=err) + +try: + from .rocrate import ROCrateEntityConverter + from .rocrate import ROCrateConverter + from .rocrate import ELNFileConverter +except ImportError as err: + ROCrateEntityConverter: type = utils.MissingImport( + name="ROCrateEntityConverter", hint="Try installing with the `rocrate` extra option.", + err=err) + ROCrateConverter: type = utils.MissingImport( + name="ROCrateConverter", hint="Try installing with the `rocrate` extra option.", + err=err) + ELNFileConverter: type = utils.MissingImport( + name="ELNFileConverter", hint="Try installing with the `rocrate` extra option.", + err=err) diff --git a/src/caoscrawler/converters/converters.py b/src/caoscrawler/converters/converters.py index 22686e0dbae26e3322059928cf3ba0b4522f672c..64a557ce4e26fd8bfd345000d3abf18bf0360117 100644 --- a/src/caoscrawler/converters/converters.py +++ b/src/caoscrawler/converters/converters.py @@ -400,6 +400,15 @@ class Converter(object, metaclass=ABCMeta): self.converters.append(Converter.converter_factory( converter_definition, converter_name, converter_registry)) + self.setup() + + def setup(self): + """ + Analogous to `cleanup`. Can be used to set up variables that are permanently + stored in this converter. + """ + pass + @staticmethod def converter_factory(definition: dict, name: str, converter_registry: dict): """Create a Converter instance of the appropriate class. @@ -619,6 +628,13 @@ class Converter(object, metaclass=ABCMeta): """ pass + def cleanup(self): + """ + This function is called when the converter runs out of scope and can be used to + clean up objects that were needed in the converter or its children. + """ + pass + class DirectoryConverter(Converter): """ diff --git a/src/caoscrawler/converters/rocrate.py b/src/caoscrawler/converters/rocrate.py new file mode 100644 index 0000000000000000000000000000000000000000..286061ef6dbe9c7caf851fe32932dee848ac55d4 --- /dev/null +++ b/src/caoscrawler/converters/rocrate.py @@ -0,0 +1,225 @@ +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Alexander Schlemmer +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""Converters take structure elements and create Records and new structure elements from them. + +This converter converts ro-crate files which may also be .eln-files. + +""" + +from __future__ import annotations + +from typing import Optional + +import rocrate +from rocrate.rocrate import ROCrate + +import linkahead as db + +from .converters import SimpleFileConverter, ConverterValidationError, Converter, convert_basic_element +from ..stores import GeneralStore, RecordStore +from ..structure_elements import (File, Directory, StructureElement, ROCrateEntity) + +from zipfile import ZipFile + +import tempfile +import os +import re + + +class ROCrateConverter(SimpleFileConverter): + + """Convert ro-crate files / directories. + """ + + def setup(self): + self._tempdir = None + + def cleanup(self): + self._tempdir.cleanup() + + def typecheck(self, element: StructureElement): + """ + Check whether the current structure element can be converted using + this converter. + """ + return isinstance(element, File) or isinstance(element, Directory) + + def match(self, element: StructureElement) -> Optional[dict]: + m = re.match(self.definition["match"], element.name) + if m is None: + return None + return m.groupdict() + + def create_children(self, generalStore: GeneralStore, element: StructureElement): + """ + Loads an ROCrate from an rocrate file or directory. + + Arguments: + ---------- + element must be a File or Directory (structure element). + + Returns: + -------- + A list with an ROCrateElement representing the contents of the .eln-file or None + in case of errors. + """ + + if isinstance(element, File): + self._tempdir = tempfile.TemporaryDirectory() + with ZipFile(element.path) as zipf: + zipf.extractall(self._tempdir.name) + crate_path = self._tempdir.name + crate = ROCrate(crate_path) + entity_ls = [] + for ent in crate.get_entities(): + entity_ls.append(ROCrateEntity(crate_path, ent)) + return entity_ls + elif isinstance(element, Directory): + # This would be an unzipped .eln file + # As this is possible for rocrate files, I think it is reasonable + # to support it as well. + raise NotImplementedError() + else: + raise ValueError("create_children was called with wrong type of StructureElement") + return None + + +class ELNFileConverter(ROCrateConverter): + + """Convert .eln-Files + See: https://github.com/TheELNConsortium/TheELNFileFormat + + These files are basically RO-Crates with some minor differences: + - The ro-crate metadata file is not on top-level within the .eln-zip-container, + but in a top-level subdirectory. + """ + + def create_children(self, generalStore: GeneralStore, element: StructureElement): + """ + Loads an ROCrate from an .eln-file or directory. + + This involves unzipping the .eln-file to a temporary folder and creating an ROCrate object + from its contents. + + Arguments: + ---------- + element must be a File or Directory (structure element). + + Returns: + -------- + A list with an ROCrateElement representing the contents of the .eln-file or None + in case of errors. + """ + + if isinstance(element, File): + self._tempdir = tempfile.TemporaryDirectory() + with ZipFile(element.path) as zipf: + zipf.extractall(self._tempdir.name) + cratep = os.listdir(self._tempdir.name) + if len(cratep) != 1: + raise RuntimeError(".eln file must contain exactly one folder") + crate_path = os.path.join(self._tempdir.name, cratep[0]) + crate = ROCrate(crate_path) + entity_ls = [] + for ent in crate.get_entities(): + entity_ls.append(ROCrateEntity(crate_path, ent)) + return entity_ls + elif isinstance(element, Directory): + # This would be an unzipped .eln file + # As this is possible for rocrate files, I think it is reasonable + # to support it as well. + raise NotImplementedError() + else: + raise ValueError("create_children was called with wrong type of StructureElement") + return None + + +class ROCrateEntityConverter(Converter): + + def typecheck(self, element: StructureElement): + """ + Check whether the current structure element can be converted using + this converter. + """ + return isinstance(element, ROCrateEntity) + + def match(self, element: StructureElement) -> Optional[dict]: + # See https://gitlab.indiscale.com/caosdb/src/caosdb-crawler/-/issues/145 + # for a suggestion for the design of the matching algorithm. + if not isinstance(element, ROCrateEntity): + raise TypeError("Element must be an instance of ROCrateEntity.") + + # Store the result of all individual regexp variable results: + vardict = {} + + if "match_entity_type" in self.definition: + m_type = re.match(self.definition["match_entity_type"], element.type) + if m_type is None: + return None + vardict.update(m_type.groupdict()) + + if "match_properties" in self.definition: + # This matcher works analogously to the attributes matcher in the XMLConverter + for prop_def_key, prop_def_value in self.definition["match_properties"].items(): + match_counter = 0 + matched_m_prop = None + matched_m_prop_value = None + for prop_key, prop_value in element.entity.properties().items(): + m_prop = re.match(prop_def_key, prop_key) + if m_prop is not None: + match_counter += 1 + matched_m_prop = m_prop + m_prop_value = re.match(prop_def_value, prop_value) + if m_prop_value is None: + return None + matched_m_prop_value = m_prop_value + if match_counter == 0: + return None + elif match_counter > 1: + raise RuntimeError("Multiple properties match the same match_prop entry.") + vardict.update(matched_m_prop.groupdict()) + vardict.update(matched_m_prop_value.groupdict()) + + return vardict + + def create_children(self, generalStore: GeneralStore, element: StructureElement): + + children = [] + + eprops = element.entity.properties() + + # Add the properties: + for name, value in eprops.items(): + children.append(convert_basic_element(value, name)) + + # Add the files: + if isinstance(element.entity, rocrate.model.file.File): + path, name = os.path.split(eprops["@id"]) + children.append(File(name, os.path.join(element.folder, path, name))) + + # Parts of this entity are added as child entities: + if "hasPart" in eprops: + for p in eprops["hasPart"]: + children.append( + ROCrateEntity(element.folder, element.entity.crate.dereference( + p["@id"]))) + + return children diff --git a/src/caoscrawler/converters/xml_converter.py b/src/caoscrawler/converters/xml_converter.py index 0f25c0c0947421f0561c42318ac0abddabb447fc..bd3f6cf0fdcc5fed5b5452da8a17a8a877009b06 100644 --- a/src/caoscrawler/converters/xml_converter.py +++ b/src/caoscrawler/converters/xml_converter.py @@ -183,6 +183,7 @@ class XMLTagConverter(Converter): # - Require unique attribute-key and attribute-value matches: Very complex # - Only allow one single attribute-key to match and run attribute-value match separately. # Currently the latter option is implemented. + # TODO: The ROCrateEntityConverter implements a very similar behavior. if match_counter == 0: return None elif match_counter > 1: diff --git a/src/caoscrawler/scanner.py b/src/caoscrawler/scanner.py index eeb2bdbf8f0f0d96579598cd8842739a3d154b93..27711e6a7c4e69df3c2d99aca7a427670b153765 100644 --- a/src/caoscrawler/scanner.py +++ b/src/caoscrawler/scanner.py @@ -400,6 +400,9 @@ def scanner(items: list[StructureElement], crawled_data, debug_tree, registered_transformer_functions) + # Clean up converter: + converter.cleanup() + if restricted_path and not path_found: raise RuntimeError("A 'restricted_path' argument was given that is not contained in " "the data tree") diff --git a/src/caoscrawler/structure_elements/__init__.py b/src/caoscrawler/structure_elements/__init__.py index 4b925a567f87febdac7b5547111a468eb0a3253c..351f1069708ec94c0dd27313b6329d89858d4330 100644 --- a/src/caoscrawler/structure_elements/__init__.py +++ b/src/caoscrawler/structure_elements/__init__.py @@ -20,4 +20,12 @@ """Submdule containing all default and optional converters.""" +from .. import utils from .structure_elements import * + +try: + from .rocrate_structure_elements import ROCrateEntity +except ImportError as err: + ROCrateEntity: type = utils.MissingImport( + name="ROCrateEntity", hint="Try installing with the `rocrate` extra option.", + err=err) diff --git a/src/caoscrawler/structure_elements/rocrate_structure_elements.py b/src/caoscrawler/structure_elements/rocrate_structure_elements.py new file mode 100644 index 0000000000000000000000000000000000000000..d39617432fcb63220d3acbb63a618b0445165388 --- /dev/null +++ b/src/caoscrawler/structure_elements/rocrate_structure_elements.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2024 Alexander Schlemmer +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# + +from rocrate.model.entity import Entity +from .structure_elements import StructureElement + + +class ROCrateEntity(StructureElement): + """ + Store entities contained in ROCrates. + """ + + def __init__(self, folder: str, entity: Entity): + """ + Initializes this ROCrateEntity. + + Arguments: + ---------- + folder: str + The folder that contains the ROCrate data. In case of a zipped ROCrate, this + is a temporary folder that the ROCrate was unzipped to. + The folder is the folder containing the ro-crate-metadata.json. + + entity: Entity + The ROCrate entity that is stored in this structure element. + The entity automatically contains an attribute ".crate" + that stores the ROCrate that this entity belongs to. It can be used + e.g. to look up links to other entities (ROCrate.dereference). + """ + super().__init__(entity.properties()["@id"]) + self.folder = folder + self.entity = entity diff --git a/tox.ini b/tox.ini index 41249e4277391c5ffa4ec13fc4da1a6ee1f48491..e003e26ecd16861c3b8a8d991fc789c78d203e5b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py38, py39, py310, py311, py312, py313 skip_missing_interpreters = true [testenv] -deps = .[h5-crawler,spss] +deps = .[h5-crawler,spss,rocrate] pytest pytest-cov # TODO: Make this f-branch sensitive diff --git a/unittests/eln_cfood.yaml b/unittests/eln_cfood.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ab8e7108f511b0450d37c3e60162e412d4a1bf3b --- /dev/null +++ b/unittests/eln_cfood.yaml @@ -0,0 +1,36 @@ +--- +metadata: + crawler-version: 0.9.2 + macros: +--- +Converters: + ELNFile: + converter: ELNFileConverter + package: caoscrawler.converters + ROCrateEntity: + converter: ROCrateEntityConverter + package: caoscrawler.converters + +DataDir: + type: Directory + match: .* + subtree: + ELNFile: + type: ELNFile + match: ^.*\.eln$ + subtree: + RecordsExample: + type: ROCrateEntity + match_type: Dataset + match_properties: + "@id": records-example/$ + name: (?P<name>.*) + keywords: (?P<keywords>.*) + description: (?P<description>.*) + dateModified: (?P<dateModified>.*) + records: + Dataset: + name: $name + keywords: $keywords + description: $description + dateModified: $dateModified diff --git a/unittests/eln_files/PASTA.eln b/unittests/eln_files/PASTA.eln new file mode 100644 index 0000000000000000000000000000000000000000..61866e7d5f57cb32191af6663be230153092e712 Binary files /dev/null and b/unittests/eln_files/PASTA.eln differ diff --git a/unittests/eln_files/records-example.eln b/unittests/eln_files/records-example.eln new file mode 100644 index 0000000000000000000000000000000000000000..09ed53fc179e80a240ab773247d6f9adee71b429 Binary files /dev/null and b/unittests/eln_files/records-example.eln differ diff --git a/unittests/test_rocrate_converter.py b/unittests/test_rocrate_converter.py new file mode 100644 index 0000000000000000000000000000000000000000..16cfc3a3c3aec811219da7006d4722d9abf6dcf7 --- /dev/null +++ b/unittests/test_rocrate_converter.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Alexander Schlemmer <a.schlemmer@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +""" +test the XML converters +""" +import importlib +import json +import pytest +import sys +import yaml +import os + +from lxml.etree import fromstring +from pathlib import Path + +from rocrate.rocrate import ROCrate +from rocrate.model.entity import Entity +import rocrate + +from caoscrawler.converters import (ELNFileConverter, ROCrateEntityConverter) +from caoscrawler.scanner import load_definition +from caoscrawler.stores import GeneralStore +from caoscrawler.structure_elements import ROCrateEntity, File, TextElement, DictElement + +from caoscrawler import scanner +from caosadvancedtools.json_schema_exporter import recordtype_to_json_schema +from caosadvancedtools.models.parser import parse_model_from_yaml +from linkahead.high_level_api import convert_to_python_object +import json +import jsonschema + +import linkahead as db + + +UNITTESTDIR = Path(__file__).parent + + +@pytest.fixture +def converter_registry(): + converter_registry: dict[str, dict[str, str]] = { + "ELNFile": { + "converter": "ELNFileConverter", + "package": "caoscrawler.converters"}, + "ROCrateEntity": { + "converter": "ROCrateEntityConverter", + "package": "caoscrawler.converters", + } + } + + for key, value in converter_registry.items(): + module = importlib.import_module(value["package"]) + value["class"] = getattr(module, value["converter"]) + return converter_registry + + +@pytest.fixture +def basic_eln_converter(converter_registry): + return ELNFileConverter(yaml.safe_load(""" +type: ELNFile +match: .*\\.eln +"""), "TestELNConverter", converter_registry) + + +@pytest.fixture +def eln_entities(basic_eln_converter): + f_k4mat = File("records-example.eln", + os.path.join(UNITTESTDIR, "eln_files", "records-example.eln")) + store = GeneralStore() + entities = basic_eln_converter.create_children(store, f_k4mat) + return entities + + +def test_load_pasta(basic_eln_converter): + """ + Test for loading the .eln example export from PASTA. + """ + f_pasta = File("PASTA.eln", os.path.join(UNITTESTDIR, "eln_files", "PASTA.eln")) + match = basic_eln_converter.match(f_pasta) + assert match is not None + entities = basic_eln_converter.create_children(GeneralStore(), f_pasta) + assert len(entities) == 20 + assert isinstance(entities[0], ROCrateEntity) + assert isinstance(entities[0].folder, str) + assert isinstance(entities[0].entity, Entity) + + +def test_load_kadi4mat(basic_eln_converter): + """ + Test for loading the .eln example export from PASTA. + """ + f_k4mat = File("records-example.eln", + os.path.join(UNITTESTDIR, "eln_files", "records-example.eln")) + match = basic_eln_converter.match(f_k4mat) + assert match is not None + entities = basic_eln_converter.create_children(GeneralStore(), f_k4mat) + assert len(entities) == 10 + assert isinstance(entities[0], ROCrateEntity) + assert isinstance(entities[0].folder, str) + assert isinstance(entities[0].entity, Entity) + + +def test_match_rocrate_entities(eln_entities): + ds1 = ROCrateEntityConverter(yaml.safe_load(""" +type: ROCrateEntity +match_properties: + "@id": \\./ + datePublished: (?P<datePublished>.*) +"""), "TestELNConverter", converter_registry) + + match = ds1.match(eln_entities[0]) + assert match is not None + + ds2 = ROCrateEntityConverter(yaml.safe_load(""" +type: ROCrateEntity +match_type: CreativeWork +match_properties: + "@id": ro-crate-metadata.json + dateCreated: (?P<dateCreated>.*) +"""), "TestELNConverter", converter_registry) + + match = ds2.match(eln_entities[0]) + assert match is None + match = ds1.match(eln_entities[1]) + assert match is None + + match = ds2.match(eln_entities[1]) + assert match is not None + assert match["dateCreated"] == "2024-08-21T12:07:45.115990+00:00" + + children = ds2.create_children(GeneralStore(), eln_entities[1]) + assert len(children) == 8 + assert isinstance(children[0], TextElement) + assert children[0].name == "@id" + assert children[0].value == "ro-crate-metadata.json" + assert isinstance(children[5], DictElement) + assert children[5].value == {'@id': 'https://kadi.iam.kit.edu'} + + +def test_file(eln_entities): + ds_csv = ROCrateEntityConverter(yaml.safe_load(""" +type: ROCrateEntity +match_type: File +match_properties: + "@id": .*\.csv$ +"""), "TestELNConverter", converter_registry) + + ent_csv = eln_entities[5] + match = ds_csv.match(ent_csv) + assert match is not None + + children = ds_csv.create_children(GeneralStore(), ent_csv) + + # Number of children = number of properties + number of files: + assert len(children) == len(ent_csv.entity.properties()) + 1 + # Get the file: + f_csv = [f for f in children if isinstance(f, File)][0] + with open(f_csv.path) as f: + text = f.read() + assert "Ultrasound Transducer" in text + + +def test_has_part(eln_entities): + ds_parts = ROCrateEntityConverter(yaml.safe_load(""" +type: ROCrateEntity +match_type: Dataset +match_properties: + "@id": records-example/ +"""), "TestELNConverter", converter_registry) + + ent_parts = eln_entities[2] + match = ds_parts.match(ent_parts) + assert match is not None + + children = ds_parts.create_children(GeneralStore(), ent_parts) + + # Number of children = number of properties + number of parts: + assert len(children) == len(ent_parts.entity.properties()) + 4 + entity_children = [f for f in children if isinstance(f, ROCrateEntity)] + assert len(entity_children) == 4 + for f in entity_children: + assert isinstance(f.entity, rocrate.model.file.File) + + +def test_scanner(): + rlist = scanner.scan_directory(os.path.join(UNITTESTDIR, "eln_files/"), + os.path.join(UNITTESTDIR, "eln_cfood.yaml")) + assert len(rlist) == 1 + assert isinstance(rlist[0], db.Record) + assert rlist[0].name == "records-example" + assert rlist[0].description == "This is a sample record." + assert rlist[0].parents[0].name == "Dataset" + assert rlist[0].get_property("keywords").value == "sample" + assert rlist[0].get_property("dateModified").value == "2024-08-21T11:43:17.626965+00:00"