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.
- etiket_sync_agent_folderbase/__init__.py +5 -0
- etiket_sync_agent_folderbase/dataset_info.py +105 -0
- etiket_sync_agent_folderbase/folderbase_config_class.py +69 -0
- etiket_sync_agent_folderbase/folderbase_sync_class.py +271 -0
- etiket_sync_agent_folderbase/local_sync_record.py +91 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/METADATA +224 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/RECORD +11 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/WHEEL +5 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/entry_points.txt +2 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/licenses/LICENCE +34 -0
- etiket_sync_agent_folderbase-0.3.0b1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|