etiket-sync-agent-folderbase 0.3.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ from etiket_sync_agent_folderbase.folderbase_sync_class import FolderBaseSync
2
+ from etiket_sync_agent_folderbase.folderbase_config_class import FolderBaseConfigData
3
+
4
+ __all__ = ["FolderBaseSync", "FolderBaseConfigData"]
5
+ __version__ = "0.3.0b1"
@@ -0,0 +1,105 @@
1
+ from etiket_sync_agent.converters.converter_abstract import FileConverter
2
+ from etiket_sync_agent.sync.manifests.v2.definitions import QH_DATASET_INFO_FILE
3
+
4
+ from typing import Union, Optional, List, Dict, Type
5
+
6
+ import yaml
7
+ import datetime
8
+ import pathlib
9
+ import warnings
10
+
11
+ def generate_dataset_info(
12
+ path: Union[str, pathlib.Path],
13
+ dataset_name: Optional[str] = None,
14
+ creation: Optional[datetime.datetime] = None,
15
+ description: Optional[str] = None,
16
+ attributes: Optional[Dict[str, Union[str, int, float]]] = None,
17
+ tags: Optional[List[str]] = None,
18
+ converters: Optional[List[Type[FileConverter]]] = None,
19
+ skip: Optional[List[str]] = None,
20
+ **kwargs) -> None:
21
+ """
22
+ Generate a dataset info YAML file for a specified path.
23
+
24
+ This function creates a `dataset_info.yml` file containing metadata about the dataset,
25
+ including its name, creation time, description, attributes, tags, converters, and
26
+ patterns of files or folders to skip during synchronization.
27
+
28
+ Args:
29
+ path (Union[str, Path]): The path to the dataset where the `dataset_info.yml` will be created.
30
+ If the path does not yet exists, it will be created.
31
+ dataset_name (Optional[str], optional): The name of the dataset. Defaults to `None`, which uses the directory name.
32
+ creation (Optional[datetime.datetime], optional): The creation time of the dataset.
33
+ If provided, this should be a `datetime` object.
34
+ description (Optional[str], optional): A brief description of the dataset.
35
+ attributes (Optional[Dict[str, Union[str, int, float]]], optional):
36
+ A dictionary of attributes to be added to the dataset.
37
+ tags (Optional[List[str]], optional):
38
+ A list of tags associated with the dataset.
39
+ converters (Optional[List[Type[FileConverter]]], optional):
40
+ A list of converter classes used for processing files within the dataset. Each class should be a subclass of `FileConverter`.
41
+ skip (Optional[List[str]], optional):
42
+ A list of file or folder patterns to skip during synchronization (e.g., `["*.json", "text.txt"]`).
43
+ **kwargs: Additional keyword arguments for backwards compatibility.
44
+ - keywords (deprecated): Use `tags` instead.
45
+ """
46
+ # Handle deprecated 'keywords' argument
47
+ if 'keywords' in kwargs:
48
+ warnings.warn(
49
+ "The 'keywords' argument is deprecated and will be removed in a future version. "
50
+ "Please use 'tags' instead.",
51
+ DeprecationWarning,
52
+ stacklevel=2
53
+ )
54
+ if tags is None:
55
+ tags = kwargs.pop('keywords')
56
+ else:
57
+ kwargs.pop('keywords') # Ignore if tags is already provided
58
+
59
+ # Warn about any unexpected kwargs
60
+ if kwargs:
61
+ unexpected = ', '.join(kwargs.keys())
62
+ warnings.warn(f"Unexpected keyword arguments: {unexpected}", UserWarning, stacklevel=2)
63
+
64
+ dataset_info_dict = {}
65
+ dataset_info_dict['version'] = "0.1"
66
+
67
+ if dataset_name:
68
+ dataset_info_dict['dataset_name'] = dataset_name
69
+ if creation: # format in ISO format
70
+ dataset_info_dict['creation'] = creation.strftime('%Y-%m-%dT%H:%M:%S')
71
+ if description:
72
+ dataset_info_dict['description'] = description
73
+ if attributes:
74
+ # check if the depth of the attributes is 1
75
+ if all(isinstance(v, (str, int, float)) for v in attributes.values()):
76
+ dataset_info_dict['attributes'] = attributes
77
+ else:
78
+ raise ValueError(
79
+ 'Attributes must be a dictionary with a depth of 1, '
80
+ 'and values of type str, int, or float.')
81
+ if tags:
82
+ dataset_info_dict['tags'] = tags
83
+ if converters:
84
+ if all(issubclass(c, FileConverter) for c in converters):
85
+ dataset_info_dict['converters'] = { f"{c.input_type}_to_{c.output_type}_converter" :
86
+ {'module': c.__module__, 'class': c.__name__}
87
+ for c in converters}
88
+ else:
89
+ raise ValueError('Converters must be a list of subclasses of FileConverter.')
90
+ if skip:
91
+ dataset_info_dict['skip'] = skip
92
+
93
+ dataset_path = pathlib.Path(path) if isinstance(path, str) else path
94
+
95
+ if not dataset_path.exists():
96
+ dataset_path.mkdir(parents=True)
97
+ if not dataset_path.is_dir():
98
+ raise NotADirectoryError(f"The specified path is not a directory: {dataset_path}")
99
+
100
+ output_file = dataset_path / QH_DATASET_INFO_FILE
101
+ try:
102
+ with output_file.open('w', encoding="utf-8") as f:
103
+ yaml.dump(dataset_info_dict, f, sort_keys=False)
104
+ except IOError as e:
105
+ raise IOError("Failed to write dataset info file.") from e
@@ -0,0 +1,69 @@
1
+ import pathlib
2
+ import dataclasses
3
+
4
+ from typing import Optional
5
+
6
+ from etiket_sync_agent.crud.sync_sources import crud_sync_sources, SyncSources
7
+ from etiket_sync_agent.db import get_db_session_context
8
+ from etiket_sync_agent.backends import BACKENDS
9
+
10
+
11
+ @dataclasses.dataclass
12
+ class FolderBaseConfigData:
13
+ root_directory: pathlib.Path
14
+ is_server_folder : bool
15
+
16
+ def __post_init__(self):
17
+ # ensure the path is of the type pathlib.Path (str is converted to Path) and expand ~
18
+ self.root_directory = pathlib.Path(self.root_directory).expanduser()
19
+
20
+ async def validate(self, current_sync_source : Optional[SyncSources] = None):
21
+ """
22
+ Validates the file base configuration.
23
+
24
+ Checks:
25
+ 1. If the root_directory exists and is a directory.
26
+ 2. If the root_directory conflicts with an existing folderbase sync source
27
+ (i.e., it's identical, a subdirectory, or a parent directory).
28
+
29
+ Raises:
30
+ ValueError: If any validation check fails.
31
+
32
+ Returns:
33
+ True if all checks pass.
34
+ """
35
+ try:
36
+ abs_root_dir = self.root_directory.expanduser().resolve(strict=True)
37
+ except FileNotFoundError:
38
+ raise ValueError(f"The specified root directory does not exist: {self.root_directory}")
39
+
40
+ # check if the root directory exists and is a directory.
41
+ if not abs_root_dir.is_dir():
42
+ raise ValueError(f"The specified path is not a directory: {abs_root_dir}")
43
+
44
+ # check if the folder is not yet added/is part of a folder that is already added.
45
+ async with get_db_session_context() as session:
46
+ sync_sources = await crud_sync_sources.list_sync_sources(session)
47
+ for sync_source in sync_sources:
48
+ if sync_source.backend == BACKENDS.FOLDERBASE and (current_sync_source is None or sync_source.id != current_sync_source.id):
49
+ # Assuming config_data stores the path as a string and is always present/valid.
50
+ existing_path_str = sync_source.config_data['root_directory']
51
+ existing_path = pathlib.Path(existing_path_str).expanduser().resolve()
52
+
53
+ # Check for conflicts (using samefile for case-insensitive filesystem support)
54
+ try:
55
+ is_same = abs_root_dir.samefile(existing_path)
56
+ except FileNotFoundError:
57
+ is_same = False # existing path was deleted
58
+
59
+ if is_same:
60
+ raise ValueError(f"The directory '{abs_root_dir}' is already added as sync source '{sync_source.name}'.")
61
+ # Check if the new path is inside an existing path
62
+ if abs_root_dir.is_relative_to(existing_path):
63
+ raise ValueError(f"The directory '{abs_root_dir}' is inside the directory '{existing_path}' added by sync source '{sync_source.name}'.")
64
+ # Check if an existing path is inside the new path
65
+ if existing_path.is_relative_to(abs_root_dir):
66
+ raise ValueError(f"The directory '{existing_path}' added by sync source '{sync_source.name}' is inside the specified directory '{abs_root_dir}'.")
67
+
68
+ return True
69
+
@@ -0,0 +1,271 @@
1
+ import fnmatch
2
+ import os
3
+ import pathlib
4
+ import re
5
+ import logging
6
+ import yaml
7
+ import xarray
8
+ import traceback
9
+
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Dict, List
13
+
14
+ from etiket_sync_agent.converters.converter_abstract import FileConverterHelper
15
+ from etiket_sync_agent_folderbase.folderbase_config_class import FolderBaseConfigData
16
+ from etiket_sync_agent_folderbase.local_sync_record import LocalSyncRecord
17
+ from etiket_sync_agent.exceptions.sync import NoConvertorException
18
+ from etiket_sync_agent.sync.manifests.v2.definitions import QH_DATASET_INFO_FILE, QH_MANIFEST_FILE
19
+ from etiket_sync_agent.backends.sync_source_abstract import SyncSourceFileBase, ScopeRequirement
20
+ from etiket_sync_agent.sync.sync_records.manager import SyncRecordManager
21
+ from etiket_sync_agent.sync.sync_utilities import dataset_info, file_info, FileType, sync_utilities
22
+ from etiket_sync_agent.schemas import SyncItemSchema
23
+ from etiket_sync_agent.converters.converters import get_converter
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # in the config file, the converter should be named as ExtensionA_to_ExtensionB_converter
28
+ converter_naming_scheme = re.compile(r'^(\w+)_to_(\w+)_converter$')
29
+
30
+ class FolderBaseSync(SyncSourceFileBase):
31
+ sync_agent_name = "FolderBase"
32
+ config_data_class = FolderBaseConfigData
33
+ scope_requirement = ScopeRequirement.REQUIRED
34
+ supports_scope_mapping = False
35
+ live_sync_implemented = False
36
+ has_owner = True
37
+ level = -1
38
+ is_single_file = False
39
+
40
+ @staticmethod
41
+ def rootPath(configData: FolderBaseConfigData) -> Path:
42
+ return Path(configData.root_directory)
43
+
44
+ @staticmethod
45
+ async def checkLiveDataset(configData: FolderBaseConfigData, syncIdentifier: SyncItemSchema, maxPriority: bool) -> bool:
46
+ return False
47
+
48
+ @staticmethod
49
+ async def syncDatasetNormal(configData: FolderBaseConfigData, syncIdentifier: SyncItemSchema, sync_record: SyncRecordManager):
50
+ dataset_path = Path(configData.root_directory) / syncIdentifier.dataIdentifier
51
+
52
+ with sync_record.task("Read dataset info file and manifest file."):
53
+ if not (dataset_path / QH_DATASET_INFO_FILE).exists():
54
+ raise FileNotFoundError(f"Dataset info file not found for dataset: {syncIdentifier.dataIdentifier}")
55
+ with open(dataset_path / QH_DATASET_INFO_FILE, 'r', encoding="utf-8") as f:
56
+ sync_info = yaml.safe_load(f)
57
+
58
+ local_sync_record = LocalSyncRecord(dataset_path, syncIdentifier, sync_record)
59
+
60
+ try:
61
+ with sync_record.task("Create or update dataset."):
62
+ await create_or_update_dataset(configData, syncIdentifier, sync_info, sync_record)
63
+
64
+ with sync_record.task("Check files."):
65
+ file_converters = get_file_converters(sync_info, sync_record)
66
+ skip = sync_info.get('skip', [])
67
+ skip_folders = ["*.zarr"]
68
+
69
+ async def walk_dataset(current_folder : Path):
70
+ for dir_entry in os.scandir(current_folder):
71
+ dir_path = Path(dir_entry)
72
+ name = dir_path.name
73
+ if name.startswith('.') or name in [QH_DATASET_INFO_FILE, QH_MANIFEST_FILE]:
74
+ continue
75
+ rel_path = dir_path.relative_to(dataset_path).as_posix()
76
+ if dir_entry.is_dir(follow_symlinks=False):
77
+ if dir_path.suffix != "" and not any(fnmatch.fnmatch(rel_path, pattern) for pattern in skip): # must have a suffix (e.g. .zarr)
78
+ await upload_folder(dir_path, dataset_path, syncIdentifier, sync_record, file_converters)
79
+
80
+ if any(fnmatch.fnmatch(rel_path, pattern) for pattern in skip_folders + skip):
81
+ sync_record.add_log(f"Folder {rel_path} is skipped, as per the skip list.")
82
+ continue
83
+ await walk_dataset(dir_path)
84
+ else:
85
+ if any(fnmatch.fnmatch(rel_path, pattern) for pattern in skip):
86
+ sync_record.add_log(f"File {rel_path} is skipped, as per the skip list.")
87
+ continue
88
+ await upload_file(dir_path, dataset_path, syncIdentifier, sync_record, file_converters)
89
+
90
+ await walk_dataset(dataset_path)
91
+ finally:
92
+ await local_sync_record.apply_pending_updates()
93
+ local_sync_record.write()
94
+
95
+ @staticmethod
96
+ async def syncDatasetLive(configData: FolderBaseConfigData, syncIdentifier: SyncItemSchema, sync_record: SyncRecordManager):
97
+ raise NotImplementedError("Live sync is not implemented for FolderBaseSync")
98
+
99
+ async def create_or_update_dataset(configData : FolderBaseConfigData, syncIdentifier : SyncItemSchema, sync_info : dict, sync_record: SyncRecordManager):
100
+ """
101
+ Create or update the dataset based on synchronization information.
102
+
103
+ This function gathers the dataset name, attributes, creation time, keywords, and identifiers
104
+ and creates/updates the dataset as needed in the remote server.
105
+
106
+ Args:
107
+ configData (FileBaseData): Configuration data containing the root directory.
108
+ syncIdentifier (sync_item): Synchronization identifier with dataset UUIDs and identifiers.
109
+ sync_info (dict): Dictionary containing synchronization information from the dataset info file.
110
+ """
111
+ dataset_path = pathlib.Path(configData.root_directory) / syncIdentifier.dataIdentifier
112
+
113
+ name = sync_info.get("dataset_name", dataset_path.name)
114
+ attributes = sync_info.get('attributes', {})
115
+ description = sync_info.get('description', None)
116
+ # get creation time of first file in the folder
117
+ created = get_created_time(dataset_path)
118
+
119
+ if description is None:
120
+ description = f"Dataset {name} from FileBaseGeneric"
121
+ else:
122
+ description += f"\n\nDataset source path: {dataset_path}"
123
+
124
+ if 'created' in sync_info:
125
+ try :
126
+ # get collected, and if not present, get created
127
+ created_time_str = sync_info.get('collected', None)
128
+ if created_time_str is None:
129
+ created_time_str = sync_info.get('created', None)
130
+ created = datetime.strptime(created_time_str, '%Y-%m-%dT%H:%M:%S')
131
+ except Exception:
132
+ pass
133
+
134
+ tags = sync_info.get('tags', None)
135
+ if tags is None:
136
+ tags = sync_info.get('keywords', [])
137
+ ds_info = dataset_info(name = name, datasetUUID = syncIdentifier.datasetUUID,
138
+ alt_uid = None, scopeUUID = syncIdentifier.scopeUUID,
139
+ description=description,
140
+ created = created, keywords = list(tags),
141
+ attributes = attributes)
142
+
143
+ await sync_utilities.create_or_update_dataset(False, syncIdentifier, ds_info, sync_record)
144
+
145
+ def get_created_time(dataset_path : pathlib.Path) -> datetime:
146
+ """
147
+ Get the earliest modification time among all files in the dataset.
148
+
149
+ This function traverses all files within the dataset directory (excluding specific manifest files and hidden files (UNIX))
150
+ to find the earliest modification time, which is considered the creation time of the dataset.
151
+
152
+ Args:
153
+ dataset_path (Path): Path to the dataset directory.
154
+
155
+ Returns:
156
+ datetime: The earliest modification time of the dataset files.
157
+ """
158
+ min_time = datetime.timestamp(datetime.now())
159
+
160
+ for root, _, files in os.walk(dataset_path):
161
+ for file in files:
162
+ file_path = pathlib.Path(root) / file
163
+ if file_path.name in [QH_DATASET_INFO_FILE, QH_MANIFEST_FILE] or file_path.name.startswith('.'):
164
+ continue
165
+ if file_path.stat().st_mtime < min_time:
166
+ min_time = file_path.stat().st_mtime
167
+ return datetime.fromtimestamp(min_time)
168
+
169
+ def process_name(dataset_path : Path, current_path : Path) -> str:
170
+ # if root is /A/B and current is /A/B/C/D.txt then return C/D.txt
171
+ return current_path.relative_to(dataset_path).as_posix()
172
+
173
+ def generate_converted_file_name(dataset_path : Path, current_path : Path, new_extension : str) -> str:
174
+ # get processed name
175
+ relative_path = current_path.relative_to(dataset_path)
176
+ if relative_path.suffix:
177
+ new_name = relative_path.with_suffix(f'.{new_extension}')
178
+ else:
179
+ new_name = relative_path.parent / f"{relative_path.name}.{new_extension}"
180
+ return pathlib.PurePosixPath(new_name).as_posix()
181
+
182
+ async def upload_file(file_path : Path, dataset_path : pathlib.Path,
183
+ syncIdentifier : SyncItemSchema, sync_record: SyncRecordManager,
184
+ file_converters : Dict[str, List[FileConverterHelper]]):
185
+ file = file_path.name
186
+ name = process_name(dataset_path, file_path)
187
+ if file in [QH_DATASET_INFO_FILE, QH_MANIFEST_FILE] or file.startswith('.'):
188
+ return
189
+
190
+ f_type = get_file_type(file_path)
191
+
192
+ f_info = file_info(name = name, fileName = file,
193
+ created = datetime.fromtimestamp(file_path.stat().st_mtime),
194
+ fileType = f_type, file_generator = "")
195
+ await sync_utilities.upload_file(file_path, syncIdentifier, f_info, sync_record)
196
+
197
+ # check if there is a converter for the file
198
+ extension = file_path.suffix.lstrip('.')
199
+ if extension in file_converters:
200
+ for converterHelper in file_converters[extension]:
201
+ converted_file_name = generate_converted_file_name(dataset_path, file_path, converterHelper.converter.output_type)
202
+ with sync_record.add_upload_task(converted_file_name) as file_upload_info:
203
+ with sync_record.define_converter(file_upload_info, converterHelper, file_path) as converted_file_path:
204
+ f_type = get_file_type(converted_file_path)
205
+ f_info = file_info(name = converted_file_name, fileName = converted_file_path.name,
206
+ created = datetime.now(),
207
+ fileType = f_type, file_generator = "")
208
+ await sync_utilities.upload_file(converted_file_path, syncIdentifier, f_info, sync_record)
209
+
210
+ async def upload_folder(folder_path : str, dataset_path : pathlib.Path, syncIdentifier : SyncItemSchema, sync_record: SyncRecordManager,
211
+ file_converters : Dict[str, List[FileConverterHelper]]):
212
+ '''
213
+ Function that uploads the contents of a folder as a file. Currently, this is only needed for zarr files.
214
+ '''
215
+ folder_path = pathlib.Path(folder_path)
216
+ extension = folder_path.suffix[1:]
217
+
218
+ if extension in file_converters:
219
+ for converterHelper in file_converters[extension]:
220
+ converted_file_name = generate_converted_file_name(dataset_path, folder_path, converterHelper.converter.output_type)
221
+ with sync_record.add_upload_task(converted_file_name) as file_upload_info:
222
+ with sync_record.define_converter(file_upload_info, converterHelper, folder_path) as converted_file_path:
223
+ f_type = get_file_type(converted_file_path)
224
+ f_info = file_info(name = converted_file_name, fileName = converted_file_path.name,
225
+ created = datetime.now(),
226
+ fileType = f_type, file_generator = "")
227
+
228
+ await sync_utilities.upload_file(converted_file_path, syncIdentifier, f_info, sync_record)
229
+
230
+ def get_file_type(file_path : Path) -> FileType:
231
+ f_type = FileType.UNKNOWN
232
+ if file_path.name.endswith(".json"):
233
+ f_type = FileType.JSON
234
+ if file_path.name.endswith(".txt"):
235
+ f_type = FileType.TEXT
236
+ if file_path.name.endswith(".hdf5") or file_path.name.endswith(".h5") or file_path.name.endswith(".nc"):
237
+ try:
238
+ xr_ds = xarray.open_dataset(file_path, engine='h5netcdf', invalid_netcdf=True)
239
+ xr_ds.close()
240
+ f_type = FileType.HDF5_NETCDF
241
+ except Exception:
242
+ f_type = FileType.HDF5
243
+ return f_type
244
+
245
+ def get_file_converters(sync_info : dict, sync_record: SyncRecordManager) -> Dict[str, List[FileConverterHelper]]:
246
+ converters_to_load = sync_info.get('converters', {})
247
+ if len(converters_to_load) == 0:
248
+ sync_record.add_log("No file converters found in the dataset info file.")
249
+ return {}
250
+ with sync_record.task("Loading file converters."):
251
+ converters = {}
252
+
253
+ for key in converters_to_load.keys():
254
+ if converter_naming_scheme.match(key):
255
+ try:
256
+ module_name = converters_to_load[key]['module']
257
+ class_name = converters_to_load[key]['class']
258
+ converter = get_converter(f"{module_name}:{class_name}")
259
+ converter_helper = FileConverterHelper(key, converter)
260
+ extension = converter_helper.converter.input_type.lower()
261
+ converters.setdefault(extension, []).append(converter_helper)
262
+ sync_record.add_log(f"Loaded converter {key}")
263
+ except KeyError as e:
264
+ logger.exception("Converter %s is missing module or class information.", key)
265
+ stacktrace = traceback.format_exc()
266
+ sync_record.add_error(f"Converter {key} is missing module or class information", e, stacktrace)
267
+ except NoConvertorException as e:
268
+ logger.exception("Failed to load converter %s: %s", key, str(e))
269
+ stacktrace = traceback.format_exc()
270
+ sync_record.add_error(f"Failed to load converter {key}: {str(e)}", e, stacktrace)
271
+ return converters
@@ -0,0 +1,91 @@
1
+ import yaml
2
+ import uuid
3
+ import datetime
4
+
5
+ from etiket_sync_agent.schemas import SyncItemSchema
6
+ from etiket_sync_agent.sync.sync_records.manager import SyncRecordManager
7
+ from etiket_sync_agent.sync.manifests.v2.definitions import QH_MANIFEST_FILE
8
+ from etiket_sync_agent.crud.sync_items import crud_sync_items
9
+ from etiket_sync_agent.db import get_db_session_context
10
+ from pathlib import Path
11
+
12
+
13
+
14
+ class LocalSyncRecord:
15
+ def __init__(self, dataset_path : Path, syncIdentifier : SyncItemSchema, sync_record: SyncRecordManager):
16
+ """
17
+ Small wrapper around the sync record manager to write the local record to a file (with the main function to ensure that the uuid is not swapped of the dataset in the folders.)
18
+
19
+ Args:
20
+ dataset_path (Path): The path to the dataset directory.
21
+ syncIdentifier (SyncItemSchema): The sync item object.
22
+ """
23
+ self.sync_record = sync_record
24
+ self.sync_time = datetime.datetime.now()
25
+ self.local_record_path = dataset_path / QH_MANIFEST_FILE
26
+ self.local_record = generate_empty_manifest(dataset_path,
27
+ syncIdentifier.datasetUUID,
28
+ syncIdentifier.scopeUUID)
29
+ self._pending_dataset_uuid_update = None
30
+ self._syncIdentifier = syncIdentifier
31
+
32
+ if self.local_record_path.exists():
33
+ with open(self.local_record_path, 'r', encoding="utf-8") as f:
34
+ local_record = yaml.safe_load(f)
35
+ # if the scope uuid is the same as the updated scope uuid, update the manifest
36
+ manifest_dataset_path = local_record.get('dataset_sync_path', None)
37
+ manifest_dataset_uuid = local_record.get('dataset_uuid', None)
38
+ manifest_scope_uuid = local_record.get('scope_uuid', None)
39
+
40
+ if manifest_scope_uuid == str(syncIdentifier.scopeUUID):
41
+ self.local_record = local_record
42
+
43
+ if Path(manifest_dataset_path) == dataset_path:
44
+ if manifest_dataset_uuid is not None:
45
+ if manifest_dataset_uuid != str(syncIdentifier.datasetUUID):
46
+ self._pending_dataset_uuid_update = uuid.UUID(manifest_dataset_uuid)
47
+ # update the dataset uuid
48
+ self.local_record['dataset_uuid'] = manifest_dataset_uuid
49
+ else:
50
+ self.local_record['dataset_sync_path'] = str(dataset_path)
51
+ self.local_record['dataset_uuid'] = str(syncIdentifier.datasetUUID)
52
+
53
+ async def apply_pending_updates(self):
54
+ """Apply any pending async CRUD updates (e.g. dataset UUID correction)."""
55
+ if self._pending_dataset_uuid_update is not None:
56
+ async with get_db_session_context() as session:
57
+ await crud_sync_items.update_sync_item(
58
+ session, self._syncIdentifier.id,
59
+ dataset_uuid=self._pending_dataset_uuid_update)
60
+ self._syncIdentifier.datasetUUID = self._pending_dataset_uuid_update
61
+ self._pending_dataset_uuid_update = None
62
+
63
+ def write(self):
64
+ with open(self.local_record_path, 'w', encoding="utf-8") as f:
65
+ self.local_record['files'] = self.sync_record.to_dict()['files']
66
+ self.local_record['sync_time'] = self.sync_time.isoformat()
67
+ yaml.dump(self.local_record, f)
68
+
69
+ def generate_empty_manifest(root_path: Path, dataset_uuid: uuid.UUID, scope_uuid: uuid.UUID) -> dict:
70
+ """
71
+ Generate an empty manifest dictionary.
72
+
73
+ This function creates a new manifest dictionary with default values, including version,
74
+ dataset UUID, dataset synchronization path, synchronization time, and empty files and errors entries.
75
+
76
+ Args:
77
+ root_path (Path): The root path of the dataset.
78
+ dataset_uuid (uuid.UUID): The unique identifier of the dataset.
79
+ scope_uuid (uuid.UUID): The unique identifier of the scope.
80
+
81
+ Returns:
82
+ dict: A dictionary representing an empty manifest.
83
+ """
84
+ return {
85
+ 'version': 0.1,
86
+ 'dataset_uuid': str(dataset_uuid),
87
+ 'scope_uuid': str(scope_uuid),
88
+ 'dataset_sync_path': str(root_path),
89
+ 'sync_time': datetime.datetime.now().isoformat(),
90
+ 'files': {}
91
+ }
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: etiket_sync_agent_folderbase
3
+ Version: 0.3.0b1
4
+ Summary: Folder-based backend for eTiKeT sync agent
5
+ Author: QHarbor team
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://qharbor.nl
8
+ Project-URL: Documentation, https://docs.qharbor.nl
9
+ Keywords: etiket,sync,backend,folderbase,filesystem
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENCE
27
+ Requires-Dist: etiket_sync_agent>=0.3.0b1
28
+ Requires-Dist: xarray
29
+ Requires-Dist: pyyaml
30
+ Dynamic: license-file
31
+
32
+ # eTiKeT Sync Agent - FolderBase Backend
33
+
34
+ Backend for synchronizing folder-based datasets with the eTiKeT platform. This backend scans directories for datasets marked with a `_QH_dataset_info.yaml` file and syncs their contents to the cloud.
35
+
36
+ ## How It Works
37
+
38
+ The FolderBase backend continuously watches a specified folder, automatically detects new and existing datasets, and uploads them to QHarbor. Note that it synchronizes to the server, not from the server.
39
+
40
+ A folder is recognized as a **dataset** when it contains a `_QH_dataset_info.yaml` file. This file specifies the minimum amount of information needed to create a dataset. Every other file in the folder (and subdirectories) is considered a data file and will be added to the dataset.
41
+
42
+ ### Example Folder Structure
43
+
44
+ ```
45
+ main_folder/
46
+ ├── 20240101/
47
+ │ ├── 20240101-211245-165-731d85-experiment_1/
48
+ │ │ ├── _QH_dataset_info.yaml
49
+ │ │ ├── 01-01-2024_01-01-01.json
50
+ │ │ └── 01-01-2024_01-01-01.hdf5
51
+ ├── 20240102/
52
+ │ ├── 20240102-220655-268-455d85-experiment_2/
53
+ │ │ ├── _QH_dataset_info.yaml
54
+ │ │ ├── 02-01-2024_02-02-02.json
55
+ │ │ ├── 02-01-2024_02-02-02.hdf5
56
+ │ │ └── analysis/
57
+ │ │ ├── 02-01-2024_02-02-02_analysis.json
58
+ │ │ └── 02-01-2024_02-02-02_analysis.hdf5
59
+ └── some_other_folder/
60
+ ├── _QH_dataset_info.yaml
61
+ └── 01-01-2024_01-01-01.json
62
+ ```
63
+
64
+ If a file is added to any of these folders or a new dataset folder is created, the sync agent will automatically detect and upload it.
65
+
66
+ ---
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install etiket_sync_agent_folderbase
72
+ ```
73
+
74
+ The package is automatically discovered by `etiket_sync_agent` through the entry-point system.
75
+
76
+ ---
77
+
78
+ ## Configuration
79
+
80
+ The FolderBase backend requires a `FolderBaseConfigData` configuration:
81
+
82
+ | Field | Type | Required | Description |
83
+ |-------|------|----------|-------------|
84
+ | `root_directory` | `Path` or `str` | Yes | Root directory to watch for datasets. Supports `~` expansion. |
85
+ | `is_server_folder` | `bool` | Yes | Whether this is a network/server folder (e.g., on a university network drive) |
86
+
87
+ Please use our flutter GUI or the etiket_sdk to add this sync source.
88
+
89
+ ---
90
+
91
+ ## The `_QH_dataset_info.yaml` File
92
+
93
+ When performing measurements, we recommend programmatically creating the `_QH_dataset_info.yaml` file in the dataset folder.
94
+
95
+ ### Minimal Example
96
+
97
+ ```yaml
98
+ version: 0.1
99
+ ```
100
+
101
+ ### Full Field Reference
102
+
103
+ | Field | Required | Type | Description |
104
+ |-------|----------|------|-------------|
105
+ | `version` | Yes | `str` | File format version (currently `0.1`) |
106
+ | `dataset_name` | No | `str` | Name of the dataset. Default: folder name |
107
+ | `created` | No | `str` | Creation date in format `YYYY-MM-DDTHH:MM:SS`. Default: earliest file modification time |
108
+ | `collected` | No | `str` | Collection date (alternative to `created`) |
109
+ | `description` | No | `str` | Description of the dataset |
110
+ | `attributes` | No | `dict` | Key-value pairs (values must be `str` or `number`) |
111
+ | `tags` | No | `list` | Tags for the dataset |
112
+ | `skip` | No | `list` | Glob patterns for files/folders to exclude (e.g., `["*.json", "raw_data/*"]`) |
113
+ | `converters` | No | `dict` | File converters to apply (see below) |
114
+
115
+ ### Complete Example
116
+
117
+ ```yaml
118
+ version: 0.1
119
+ dataset_name: 'my_dataset_name'
120
+ description: "Description of the experiment I want to do."
121
+ attributes:
122
+ initials: 'QH'
123
+ set_up: 'XLD001'
124
+ sample: 'my_sample'
125
+ tags: ['rabi', 'test']
126
+ skip: ['*.json', 'raw_data/*']
127
+ converters:
128
+ csv_to_hdf5_converter:
129
+ module: etiket_sync_agent_qh_converters
130
+ class: CSVToHDF5Converter
131
+ ```
132
+
133
+ > ⚠️ **Note**: The YAML file must use **spaces** for indentation, not tabs. Using tabs will cause parsing errors and synchronization will fail.
134
+
135
+ ---
136
+
137
+ ## File Converters
138
+
139
+ You can specify converters to automatically transform files during sync. The naming convention is `{input}_to_{output}_converter`.
140
+
141
+ ### Converter Syntax
142
+
143
+ ```yaml
144
+ converters:
145
+ txt_to_csv_converter:
146
+ module: my_library.location.to.module
147
+ class: MyConverterClass
148
+ ```
149
+
150
+ ### Available Converters
151
+
152
+ The `etiket_sync_agent_qh_converters` package provides built-in converters:
153
+ - `zarr` → HDF5
154
+ - CSV → HDF5
155
+ - And more...
156
+
157
+ To create custom converters, implement a class that inherits from `FileConverter` and provides the `convert` method. The converter can be installed with the `etiket_sdk` package. For more information on creating converters, see the `etiket_sync_agent` package documentation.
158
+
159
+ ---
160
+
161
+ ## Programmatic Dataset Creation
162
+
163
+ You can programmatically create the `_QH_dataset_info.yaml` file using the `generate_dataset_info` function:
164
+
165
+ ```python
166
+ from datetime import datetime
167
+ from etiket_sync_agent_folderbase import generate_dataset_info
168
+ from etiket_sync_agent_qh_converters import CSVToHDF5Converter
169
+
170
+ path = "my_path/test/"
171
+ generate_dataset_info(
172
+ path,
173
+ dataset_name="my_dataset_name",
174
+ creation=datetime.now(),
175
+ description="Description of the experiment I want to do.",
176
+ attributes={"sample": "my_sample"},
177
+ tags=["rabi", "test"],
178
+ converters=[CSVToHDF5Converter],
179
+ skip=["*.json", "raw_data/*"]
180
+ )
181
+ ```
182
+
183
+ > **Note**: This function is also re-exported by the `qdrive` package as `qdrive.dataset.generate_dataset_info`.
184
+
185
+ See [`dataset_info.py`](etiket_sync_agent_folderbase/dataset_info.py) for the full function signature and documentation.
186
+
187
+ ---
188
+
189
+ ## What Gets Synchronized
190
+
191
+ | Source | eTiKeT Field | Description |
192
+ |--------|--------------|-------------|
193
+ | `dataset_name` or folder name | `name` | Name of the dataset |
194
+ | `description` | `description` | Dataset description (appended with source path) |
195
+ | `created`/`collected` or earliest file mtime | `collected` | Dataset creation time |
196
+ | `tags` | `tags` | Searchable tags |
197
+ | `attributes` | `attributes` | Key-value metadata |
198
+ | All files (except skipped) | Data files | Uploaded with detected file type |
199
+
200
+ ### Supported File Types
201
+
202
+ Any file type is supported. For zarr files (which are actually folders), use a converter from `etiket_sync_agent_qh_converters` to convert them to HDF5.
203
+
204
+ ---
205
+
206
+ ## Features
207
+
208
+ - **Directory-based dataset discovery**: Automatically finds datasets by `_QH_dataset_info.yaml` presence
209
+ - **YAML-based configuration**: Simple declarative dataset metadata
210
+ - **File converter support**: Transform files during sync (e.g., zarr → HDF5, CSV → HDF5)
211
+ - **Skip patterns**: Exclude files/folders using glob patterns
212
+ - **Automatic file type detection**: Detects JSON, text, HDF5/NetCDF files
213
+ - **Subdirectory support**: Syncs all files recursively within dataset folders
214
+
215
+ ## Requirements
216
+
217
+ - Python >= 3.10
218
+ - xarray
219
+ - h5netcdf
220
+ - PyYAML
221
+
222
+ ## License
223
+
224
+ Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
@@ -0,0 +1,11 @@
1
+ etiket_sync_agent_folderbase/__init__.py,sha256=LwS_Se8MXrmsKxvseQcIaUIH4AMA9xVYYZ4SrAmuw1Q,242
2
+ etiket_sync_agent_folderbase/dataset_info.py,sha256=ku15iZPOsI_LKKsiHaHCuzGd2E9CKeCwHI7PpCv4fjk,4796
3
+ etiket_sync_agent_folderbase/folderbase_config_class.py,sha256=2_kmE38GVXbVVcBjjuURrM83s0IqY-NewIy9SR4n4qM,3328
4
+ etiket_sync_agent_folderbase/folderbase_sync_class.py,sha256=GESKAjsN4asBDETnyduZpOBnDAduuxWbudtaynAk9cY,13655
5
+ etiket_sync_agent_folderbase/local_sync_record.py,sha256=gv-la6QBYv4yLlZ4zFcAFNCLhHq9ZFF3CEMX0gEdX0Y,4317
6
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/licenses/LICENCE,sha256=tdZwE43Th9efUgN8-4UpMUyh0kYQ4Dk678agSuhpnc0,2357
7
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/METADATA,sha256=tzwl5DhOrBc5uiFKgeMSLNTs8JbT5PQ95qULEsu1qms,8030
8
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/entry_points.txt,sha256=TABh3YgDWyzd6ce1r_1z2V3YY9345KiZxGsEHgqLPKo,97
10
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/top_level.txt,sha256=_gGRYxwsTiNsFcopQ-1b4Cygybhzct16Cr7a6CtdvlQ,29
11
+ etiket_sync_agent_folderbase-0.3.0b1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [etiket_sync_agent.backends]
2
+ folderbase_sync_agent = etiket_sync_agent_folderbase:FolderBaseSync
@@ -0,0 +1,34 @@
1
+ All Rights Reserved License
2
+ Copyright ©️ 2024-2026 QHarbor B.V. All Rights Reserved.
3
+
4
+ Agreement to Terms
5
+ By accessing, downloading, installing, or viewing this Software (including its source code, binaries, or application files), you acknowledge and agree to the terms outlined below.
6
+
7
+ Terms and Conditions
8
+ This software and its source code (the "Software") are the exclusive property of QHarbor B.V. and are protected by copyright and other intellectual property laws.
9
+
10
+ License and Testing Exemptions
11
+ Commercial License: If you have entered into a separate commercial license agreement with QHarbor B.V., the terms of that agreement shall supersede the restrictions listed below.
12
+ Testing Permission: If you have obtained written permission for testing/evaluation from QHarbor B.V., you are permitted to install and use the Software for evaluation purposes. However, this permission strictly excludes the right to modify, alter, create derivative works, or reverse engineer the Software.
13
+
14
+ Prohibited Actions
15
+ Unless explicitly authorized by the exemptions above, you are NOT permitted to:
16
+ • Copy, reproduce, or duplicate the Software in any form (except as reasonably necessary for viewing or authorized installation)
17
+ • Modify, alter, or create derivative works based on the Software
18
+ • Distribute, publish, or share the Software with others
19
+ • Reverse engineer, decompile, or disassemble the Software
20
+ • Use the Software for any commercial or non-commercial purposes
21
+ • Transfer, sell, lease, or sublicense the Software
22
+ • Remove or alter any copyright notices or proprietary markings
23
+
24
+ Viewing Only
25
+ For those without a commercial license or written testing permission, the Software is made available for viewing and reference purposes only. Any access to view the source code or application does not grant any rights to use, copy, or modify the Software.
26
+
27
+ No Implied Rights
28
+ No rights are granted by implication, estoppel, or otherwise. All rights not expressly granted are reserved by the copyright holder.
29
+
30
+ Governing Law
31
+ This license and any disputes arising from it shall be governed by the laws of the Netherlands.
32
+
33
+ Disclaimer
34
+ This Software is provided "AS IS" without warranty of any kind. The copyright holder disclaims all warranties and shall not be liable for any damages arising from the use or inability to use this Software.
@@ -0,0 +1 @@
1
+ etiket_sync_agent_folderbase