diff --git a/.docker/tests/test_aiida.py b/.docker/tests/test_aiida.py index 37040e3c06..7f952bd855 100644 --- a/.docker/tests/test_aiida.py +++ b/.docker/tests/test_aiida.py @@ -6,7 +6,7 @@ def test_correct_python_version_installed(aiida_exec, python_version): - info = json.loads(aiida_exec('mamba list --json --full-name python', ignore_stderr=True).decode())[0] + info = json.loads(aiida_exec('mamba list --json --full-name python').decode())[0] assert info['name'] == 'python' assert parse(info['version']) == parse(python_version) @@ -15,7 +15,7 @@ def test_correct_pgsql_version_installed(aiida_exec, pgsql_version, variant): if variant == 'aiida-core-base': pytest.skip('PostgreSQL is not installed in the base image') - info = json.loads(aiida_exec('mamba list --json --full-name postgresql', ignore_stderr=True).decode())[0] + info = json.loads(aiida_exec('mamba list --json --full-name postgresql').decode())[0] assert info['name'] == 'postgresql' assert parse(info['version']).major == parse(pgsql_version).major diff --git a/.github/system_tests/test_daemon.py b/.github/system_tests/test_daemon.py index c49cb1f750..3ce8f533db 100644 --- a/.github/system_tests/test_daemon.py +++ b/.github/system_tests/test_daemon.py @@ -437,6 +437,8 @@ def launch_all(): run_multiply_add_workchain() # Testing the stashing functionality + # To speedup, here we only check with StashMode.COPY. + # All stash_modes are tested separatly in test_execmanager.py print('Testing the stashing functionality') process, inputs, expected_result = create_calculation_process(code=code_doubler, inputval=1) with tempfile.TemporaryDirectory() as tmpdir: @@ -444,7 +446,11 @@ def launch_all(): shutil.rmtree(tmpdir, ignore_errors=True) source_list = ['output.txt', 'triple_value.*'] - inputs['metadata']['options']['stash'] = {'target_base': tmpdir, 'source_list': source_list} + inputs['metadata']['options']['stash'] = { + 'stash_mode': StashMode.COPY.value, + 'target_base': tmpdir, + 'source_list': source_list, + } _, node = run.get_node(process, **inputs) assert node.is_finished_ok assert 'remote_stash' in node.outputs diff --git a/pyproject.toml b/pyproject.toml index a0b0b5bed0..25e4f30f67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,6 +114,7 @@ requires-python = '>=3.9' 'core.orbital' = 'aiida.orm.nodes.data.orbital:OrbitalData' 'core.remote' = 'aiida.orm.nodes.data.remote.base:RemoteData' 'core.remote.stash' = 'aiida.orm.nodes.data.remote.stash.base:RemoteStashData' +'core.remote.stash.compress' = 'aiida.orm.nodes.data.remote.stash.compress:RemoteStashCompressedData' 'core.remote.stash.folder' = 'aiida.orm.nodes.data.remote.stash.folder:RemoteStashFolderData' 'core.singlefile' = 'aiida.orm.nodes.data.singlefile:SinglefileData' 'core.str' = 'aiida.orm.nodes.data.str:Str' diff --git a/src/aiida/common/datastructures.py b/src/aiida/common/datastructures.py index 0f5c009a89..84d044c2c0 100644 --- a/src/aiida/common/datastructures.py +++ b/src/aiida/common/datastructures.py @@ -22,6 +22,10 @@ class StashMode(Enum): """Mode to use when stashing files from the working directory of a completed calculation job for safekeeping.""" COPY = 'copy' + COMPRESS_TAR = 'tar' + COMPRESS_TARBZ2 = 'tar.bz2' + COMPRESS_TARGZ = 'tar.gz' + COMPRESS_TARXZ = 'tar.xz' class CalcJobState(Enum): diff --git a/src/aiida/engine/daemon/execmanager.py b/src/aiida/engine/daemon/execmanager.py index 5afb594fb5..1ad044b406 100644 --- a/src/aiida/engine/daemon/execmanager.py +++ b/src/aiida/engine/daemon/execmanager.py @@ -435,56 +435,106 @@ async def stash_calculation(calculation: CalcJobNode, transport: Transport) -> N :param transport: an already opened transport. """ from aiida.common.datastructures import StashMode - from aiida.orm import RemoteStashFolderData + from aiida.orm import RemoteStashCompressedData, RemoteStashFolderData logger_extra = get_dblogger_extra(calculation) stash_options = calculation.get_option('stash') - stash_mode = stash_options.get('mode', StashMode.COPY.value) + stash_mode = stash_options.get('stash_mode') source_list = stash_options.get('source_list', []) + uuid = calculation.uuid + source_basepath = Path(calculation.get_remote_workdir()) if not source_list: return - if stash_mode != StashMode.COPY.value: - EXEC_LOGGER.warning(f'stashing mode {stash_mode} is not implemented yet.') + if stash_mode not in [mode.value for mode in StashMode.__members__.values()]: + EXEC_LOGGER.warning(f'stashing mode {stash_mode} is not supported. Stashing skipped.') return - cls = RemoteStashFolderData + EXEC_LOGGER.debug( + f'stashing files with mode {stash_mode} for calculation<{calculation.pk}>: {source_list}', extra=logger_extra + ) - EXEC_LOGGER.debug(f'stashing files for calculation<{calculation.pk}>: {source_list}', extra=logger_extra) + if stash_mode == StashMode.COPY.value: + target_basepath = Path(stash_options['target_base']) / uuid[:2] / uuid[2:4] / uuid[4:] - uuid = calculation.uuid - source_basepath = Path(calculation.get_remote_workdir()) - target_basepath = Path(stash_options['target_base']) / uuid[:2] / uuid[2:4] / uuid[4:] - - for source_filename in source_list: - if transport.has_magic(source_filename): - copy_instructions = [] - for globbed_filename in await transport.glob_async(source_basepath / source_filename): - target_filepath = target_basepath / Path(globbed_filename).relative_to(source_basepath) - copy_instructions.append((globbed_filename, target_filepath)) - else: - copy_instructions = [(source_basepath / source_filename, target_basepath / source_filename)] + for source_filename in source_list: + if transport.has_magic(source_filename): + copy_instructions = [] + for globbed_filename in await transport.glob_async(source_basepath / source_filename): + target_filepath = target_basepath / Path(globbed_filename).relative_to(source_basepath) + copy_instructions.append((globbed_filename, target_filepath)) + else: + copy_instructions = [(source_basepath / source_filename, target_basepath / source_filename)] + + for source_filepath, target_filepath in copy_instructions: + # If the source file is in a (nested) directory, create those directories first in the target directory + target_dirname = target_filepath.parent + await transport.makedirs_async(target_dirname, ignore_existing=True) + + try: + await transport.copy_async(source_filepath, target_filepath) + except (OSError, ValueError) as exception: + EXEC_LOGGER.warning(f'failed to stash {source_filepath} to {target_filepath}: {exception}') + else: + EXEC_LOGGER.debug(f'stashed {source_filepath} to {target_filepath}') + + remote_stash = RemoteStashFolderData( + computer=calculation.computer, + target_basepath=str(target_basepath), + stash_mode=StashMode(stash_mode), + source_list=source_list, + ).store() + + elif stash_mode in [ + StashMode.COMPRESS_TAR.value, + StashMode.COMPRESS_TARBZ2.value, + StashMode.COMPRESS_TARGZ.value, + StashMode.COMPRESS_TARXZ.value, + ]: + # stash_mode values are identical with compression_format in transport plugin: + # 'tar', 'tar.gz', 'tar.bz2', or 'tar.xz' + compression_format = stash_mode + file_name = stash_options.get('file_name', uuid) + dereference = stash_options.get('dereference', False) + target_basepath = Path(stash_options['target_base']) + authinfo = calculation.get_authinfo() + aiida_remote_base = authinfo.get_workdir().format(username=transport.whoami()) + + target_destination = str(target_basepath / file_name) + '.' + compression_format + + remote_stash = RemoteStashCompressedData( + computer=calculation.computer, + target_basepath=target_destination, + stash_mode=StashMode(stash_mode), + source_list=source_list, + compression_format=compression_format, + dereference=dereference, + ) - for source_filepath, target_filepath in copy_instructions: - # If the source file is in a (nested) directory, create those directories first in the target directory - target_dirname = target_filepath.parent - await transport.makedirs_async(target_dirname, ignore_existing=True) + source_list_abs = [source_basepath / source for source in source_list] + + try: + await transport.compress_async( + format=compression_format, + remotesources=source_list_abs, + remotedestination=target_destination, + rootdir=aiida_remote_base, + overwrite=False, + dereference=dereference, + ) + except (OSError, ValueError) as exception: + EXEC_LOGGER.warning(f'failed to compress {source_list} to {target_destination}: {exception}') + return + # note: if you raise here, you triger the exponential backoff + # and if you don't raise appreas as succesful in verdi process list: Finished [0] + # open an issue to fix this + # raise exceptions.RemoteOperationError(f'failed ' + # 'to compress {source_list} to {target_destination}: {exception}') + + remote_stash.store() - try: - await transport.copy_async(source_filepath, target_filepath) - except (OSError, ValueError) as exception: - EXEC_LOGGER.warning(f'failed to stash {source_filepath} to {target_filepath}: {exception}') - else: - EXEC_LOGGER.debug(f'stashed {source_filepath} to {target_filepath}') - - remote_stash = cls( - computer=calculation.computer, - target_basepath=str(target_basepath), - stash_mode=StashMode(stash_mode), - source_list=source_list, - ).store() remote_stash.base.links.add_incoming(calculation, link_type=LinkType.CREATE, link_label='remote_stash') diff --git a/src/aiida/engine/processes/calcjobs/calcjob.py b/src/aiida/engine/processes/calcjobs/calcjob.py index 8133ca1c8f..f03388805b 100644 --- a/src/aiida/engine/processes/calcjobs/calcjob.py +++ b/src/aiida/engine/processes/calcjobs/calcjob.py @@ -116,7 +116,7 @@ def validate_stash_options(stash_options: Any, _: Any) -> Optional[str]: target_base = stash_options.get('target_base', None) source_list = stash_options.get('source_list', None) - stash_mode = stash_options.get('mode', StashMode.COPY.value) + stash_mode = stash_options.get('stash_mode', None) if not isinstance(target_base, str) or not os.path.isabs(target_base): return f'`metadata.options.stash.target_base` should be an absolute filepath, got: {target_base}' @@ -130,9 +130,23 @@ def validate_stash_options(stash_options: Any, _: Any) -> Optional[str]: try: StashMode(stash_mode) except ValueError: - port = 'metadata.options.stash.mode' + port = 'metadata.options.stash.stash_mode' return f'`{port}` should be a member of aiida.common.datastructures.StashMode, got: {stash_mode}' + if stash_mode in [ + StashMode.COMPRESS_TAR.value, + StashMode.COMPRESS_TARBZ2.value, + StashMode.COMPRESS_TARGZ.value, + StashMode.COMPRESS_TARXZ.value, + ]: + dereference = stash_options.get('dereference') + if not isinstance(dereference, bool): + return f'`metadata.options.stash.dereference` should be a boolean, got: {dereference}' + + file_name = stash_options.get('file_name') + if not isinstance(file_name, str): + return f'`metadata.options.stash.file_name` should be a string, got: {file_name}' + return None @@ -415,7 +429,19 @@ def define(cls, spec: CalcJobProcessSpec) -> None: # type: ignore[override] required=False, help='Mode with which to perform the stashing, should be value of `aiida.common.datastructures.StashMode`.', ) - + spec.input( + 'metadata.options.stash.dereference', + valid_type=bool, + required=False, + help='Whether to follow symlinks while stashing or not, specific to StashMode.COMPRESS', + ) + spec.input( + 'metadata.options.stash.file_name', + valid_type=str, + required=False, + help='File name to be assigned to the compressed file. If not provided, ' + 'the UUID of the original calculation is chosen by default. Specific to StashMode.COMPRESS', + ) spec.output( 'remote_folder', valid_type=orm.RemoteData, @@ -434,7 +460,6 @@ def define(cls, spec: CalcJobProcessSpec) -> None: # type: ignore[override] help='Files that are retrieved by the daemon will be stored in this node. By default the stdout and stderr ' 'of the scheduler will be added, but one can add more by specifying them in `CalcInfo.retrieve_list`.', ) - spec.exit_code( 100, 'ERROR_NO_RETRIEVED_FOLDER', diff --git a/src/aiida/orm/__init__.py b/src/aiida/orm/__init__.py index 49a04d9ba1..947f0fbf5d 100644 --- a/src/aiida/orm/__init__.py +++ b/src/aiida/orm/__init__.py @@ -88,6 +88,7 @@ 'QbFields', 'QueryBuilder', 'RemoteData', + 'RemoteStashCompressedData', 'RemoteStashData', 'RemoteStashFolderData', 'SinglefileData', diff --git a/src/aiida/orm/nodes/__init__.py b/src/aiida/orm/nodes/__init__.py index e3d665c4b9..2a19af39bf 100644 --- a/src/aiida/orm/nodes/__init__.py +++ b/src/aiida/orm/nodes/__init__.py @@ -50,6 +50,7 @@ 'ProcessNode', 'ProjectionData', 'RemoteData', + 'RemoteStashCompressedData', 'RemoteStashData', 'RemoteStashFolderData', 'SinglefileData', diff --git a/src/aiida/orm/nodes/data/__init__.py b/src/aiida/orm/nodes/data/__init__.py index d7155285ad..c360d9d8e6 100644 --- a/src/aiida/orm/nodes/data/__init__.py +++ b/src/aiida/orm/nodes/data/__init__.py @@ -58,6 +58,7 @@ 'PortableCode', 'ProjectionData', 'RemoteData', + 'RemoteStashCompressedData', 'RemoteStashData', 'RemoteStashFolderData', 'SinglefileData', diff --git a/src/aiida/orm/nodes/data/remote/__init__.py b/src/aiida/orm/nodes/data/remote/__init__.py index 11c1ed0fa6..47b9d1ffaf 100644 --- a/src/aiida/orm/nodes/data/remote/__init__.py +++ b/src/aiida/orm/nodes/data/remote/__init__.py @@ -9,8 +9,9 @@ __all__ = ( 'RemoteData', + 'RemoteStashCompressedData', 'RemoteStashData', - 'RemoteStashFolderData', + 'RemoteStashFolderData' ) # fmt: on diff --git a/src/aiida/orm/nodes/data/remote/stash/__init__.py b/src/aiida/orm/nodes/data/remote/stash/__init__.py index 30472833f8..f7f80f8680 100644 --- a/src/aiida/orm/nodes/data/remote/stash/__init__.py +++ b/src/aiida/orm/nodes/data/remote/stash/__init__.py @@ -5,11 +5,13 @@ # fmt: off from .base import * +from .compress import * from .folder import * __all__ = ( + 'RemoteStashCompressedData', 'RemoteStashData', - 'RemoteStashFolderData', + 'RemoteStashFolderData' ) # fmt: on diff --git a/src/aiida/orm/nodes/data/remote/stash/compress.py b/src/aiida/orm/nodes/data/remote/stash/compress.py new file mode 100644 index 0000000000..2f8c455373 --- /dev/null +++ b/src/aiida/orm/nodes/data/remote/stash/compress.py @@ -0,0 +1,117 @@ +"""Data plugin that models a stashed folder on a remote computer.""" + +from typing import List, Tuple, Union + +from aiida.common.datastructures import StashMode +from aiida.common.lang import type_check +from aiida.orm.fields import add_field + +from .base import RemoteStashData + +__all__ = ('RemoteStashCompressedData',) + + +class RemoteStashCompressedData(RemoteStashData): + """ """ + + _storable = True + + __qb_fields__ = [ + add_field( + 'target_basepath', + dtype=str, + doc='The the target basepath', + ), + add_field( + 'source_list', + dtype=List[str], + doc='The list of source files that were stashed', + ), + add_field( + 'dereference', + dtype=bool, + doc='The format of the compression used when stashed', + ), + ] + + def __init__( + self, + stash_mode: StashMode, + target_basepath: str, + source_list: List, + dereference: bool, + **kwargs, + ): + """Construct a new instance + + :param stash_mode: the stashing mode with which the data was stashed on the remote. + :param target_basepath: absolute path to place the compressed file (path+filename). + :param source_list: the list of source files. + """ + super().__init__(stash_mode, **kwargs) + self.target_basepath = target_basepath + self.source_list = source_list + self.dereference = dereference + + if stash_mode not in [ + StashMode.COMPRESS_TAR, + StashMode.COMPRESS_TARBZ2, + StashMode.COMPRESS_TARGZ, + StashMode.COMPRESS_TARXZ, + ]: + raise TypeError( + '`RemoteStashCompressedData` can only be used with `stash_mode` being either ' + '`StashMode.COMPRESS_TAR`, `StashMode.COMPRESS_TARGZ`, ' + '`StashMode.COMPRESS_TARBZ2` or `StashMode.COMPRESS_TARXZ`.' + ) + + @property + def dereference(self) -> bool: + """Return the dereference boolean. + + :return: the dereference boolean. + """ + return self.base.attributes.get('dereference') + + @dereference.setter + def dereference(self, value: bool): + """Set the dereference boolean. + + :param value: the dereference boolean. + """ + type_check(value, bool) + self.base.attributes.set('dereference', value) + + @property + def target_basepath(self) -> str: + """Return the target basepath. + + :return: the target basepath. + """ + return self.base.attributes.get('target_basepath') + + @target_basepath.setter + def target_basepath(self, value: str): + """Set the target basepath. + + :param value: the target basepath. + """ + type_check(value, str) + self.base.attributes.set('target_basepath', value) + + @property + def source_list(self) -> Union[List, Tuple]: + """Return the list of source files that were stashed. + + :return: the list of source files. + """ + return self.base.attributes.get('source_list') + + @source_list.setter + def source_list(self, value: Union[List, Tuple]): + """Set the list of source files that were stashed. + + :param value: the list of source files. + """ + type_check(value, (list, tuple)) + self.base.attributes.set('source_list', value) diff --git a/tests/engine/processes/calcjobs/test_calc_job.py b/tests/engine/processes/calcjobs/test_calc_job.py index 9a2078fec6..c19cd053a9 100644 --- a/tests/engine/processes/calcjobs/test_calc_job.py +++ b/tests/engine/processes/calcjobs/test_calc_job.py @@ -1097,10 +1097,65 @@ def test_additional_retrieve_list(generate_process, fixture_sandbox): ({'target_base': '/path', 'source_list': ['/abspath']}, '`metadata.options.stash.source_list` should be'), ( {'target_base': '/path', 'source_list': ['rel/path'], 'mode': 'test'}, - '`metadata.options.stash.mode` should be', + '`metadata.options.stash.stash_mode` should be', ), ({'target_base': '/path', 'source_list': ['rel/path']}, None), - ({'target_base': '/path', 'source_list': ['rel/path'], 'mode': StashMode.COPY.value}, None), + ({'target_base': '/path', 'source_list': ['rel/path'], 'stash_mode': StashMode.COPY.value}, None), + ({'target_base': '/path', 'source_list': ['rel/path'], 'stash_mode': StashMode.COMPRESS_TAR.value}, None), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TAR.value, + 'dereference': 'False', + }, + '`metadata.options.stash.dereference` should be', + ), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TAR.value, + 'dereference': True, + }, + None, + ), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TAR.value, + 'dereference': False, + }, + None, + ), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TARBZ2.value, + 'dereference': False, + }, + None, + ), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TARGZ.value, + 'dereference': False, + }, + None, + ), + ( + { + 'target_base': '/path', + 'source_list': ['rel/path'], + 'stash_mode': StashMode.COMPRESS_TARXZ.value, + 'dereference': False, + }, + None, + ), ), ) def test_validate_stash_options(stash_options, expected): diff --git a/tests/orm/nodes/data/test_remote_stash.py b/tests/orm/nodes/data/test_remote_stash.py index 0cb555f0e4..39c7cbd8e8 100644 --- a/tests/orm/nodes/data/test_remote_stash.py +++ b/tests/orm/nodes/data/test_remote_stash.py @@ -12,7 +12,7 @@ from aiida.common.datastructures import StashMode from aiida.common.exceptions import StoringNotAllowed -from aiida.orm import RemoteStashData, RemoteStashFolderData +from aiida.orm import RemoteStashCompressedData, RemoteStashData, RemoteStashFolderData def test_base_class(): @@ -24,7 +24,7 @@ def test_base_class(): @pytest.mark.parametrize('store', (False, True)) -def test_constructor(store): +def test_constructor_folder(store): """Test the constructor and storing functionality.""" stash_mode = StashMode.COPY target_basepath = '/absolute/path' @@ -53,7 +53,7 @@ def test_constructor(store): ('source_list', ('/absolute/path')), ), ) -def test_constructor_invalid(argument, value): +def test_constructor_invalid_folder(argument, value): """Test the constructor for invalid argument types.""" kwargs = { 'stash_mode': StashMode.COPY, @@ -64,3 +64,53 @@ def test_constructor_invalid(argument, value): with pytest.raises(TypeError): kwargs[argument] = value RemoteStashFolderData(**kwargs) + + +@pytest.mark.parametrize('store', (False, True)) +@pytest.mark.parametrize( + 'stash_mode', + [StashMode.COMPRESS_TAR, StashMode.COMPRESS_TARBZ2, StashMode.COMPRESS_TARGZ, StashMode.COMPRESS_TARXZ], +) +@pytest.mark.parametrize('dereference', (False, True)) +def test_constructor_compressed(store, stash_mode, dereference): + """Test the constructor and storing functionality.""" + target_basepath = '/absolute/path/foo.tar.gz' + source_list = ['relative/folder', 'relative/file'] + + data = RemoteStashCompressedData(stash_mode, target_basepath, source_list, dereference) + + assert data.stash_mode == stash_mode + assert data.target_basepath == target_basepath + assert data.source_list == source_list + + if store: + data.store() + assert data.is_stored + assert data.stash_mode == stash_mode + assert data.target_basepath == target_basepath + assert data.source_list == source_list + assert data.dereference == dereference + + +@pytest.mark.parametrize( + 'argument, value', + ( + ('stash_mode', 'compress'), + ('target_basepath', ['list']), + ('source_list', 'relative/path'), + ('source_list', ('/absolute/path')), + ('dereference', 'False'), + ), +) +def test_constructor_invalid_compressed(argument, value): + """Test the constructor for invalid argument types.""" + kwargs = { + 'stash_mode': StashMode.COMPRESS_TAR, + 'target_basepath': '/absolute/path', + 'source_list': ('relative/folder', 'relative/file'), + 'dereference': False, + } + + with pytest.raises(TypeError): + kwargs[argument] = value + RemoteStashCompressedData(**kwargs) diff --git a/tests/orm/test_fields/fields_aiida.data.core.remote.stash.compress.RemoteStashCompressedData.yml b/tests/orm/test_fields/fields_aiida.data.core.remote.stash.compress.RemoteStashCompressedData.yml new file mode 100644 index 0000000000..a0f9e96a55 --- /dev/null +++ b/tests/orm/test_fields/fields_aiida.data.core.remote.stash.compress.RemoteStashCompressedData.yml @@ -0,0 +1,16 @@ +attributes: QbDictField('attributes', dtype=Dict[str, Any], is_attribute=False, is_subscriptable=True) +ctime: QbNumericField('ctime', dtype=datetime, is_attribute=False) +dereference: QbField('dereference', dtype=bool, is_attribute=True) +description: QbStrField('description', dtype=str, is_attribute=False) +extras: QbDictField('extras', dtype=Dict[str, Any], is_attribute=False, is_subscriptable=True) +label: QbStrField('label', dtype=str, is_attribute=False) +mtime: QbNumericField('mtime', dtype=datetime, is_attribute=False) +node_type: QbStrField('node_type', dtype=str, is_attribute=False) +pk: QbNumericField('pk', dtype=int, is_attribute=False) +repository_metadata: QbDictField('repository_metadata', dtype=Dict[str, Any], is_attribute=False) +source: QbDictField('source', dtype=Optional[dict], is_attribute=True, is_subscriptable=True) +source_list: QbArrayField('source_list', dtype=List[str], is_attribute=True) +stash_mode: QbStrField('stash_mode', dtype=str, is_attribute=True) +target_basepath: QbStrField('target_basepath', dtype=str, is_attribute=True) +user_pk: QbNumericField('user_pk', dtype=int, is_attribute=False) +uuid: QbStrField('uuid', dtype=str, is_attribute=False)