etiket-sync-agent-qualibrate 0.0.1__tar.gz

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,34 @@
1
+ All Rights Reserved License
2
+ Copyright ©️ 2026 Quantum Machines Ltd. 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 Quantum Machines Ltd. 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 Quantum Machines Ltd., the terms of that agreement shall supersede the restrictions listed below.
12
+ Testing Permission: If you have obtained written permission for testing/evaluation from Quantum Machines Ltd., 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,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: etiket_sync_agent_qualibrate
3
+ Version: 0.0.1
4
+ Summary: QUAlibrate connector 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,connector,qualibrate,quantum
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: numpy
29
+ Requires-Dist: xarray
30
+ Requires-Dist: h5netcdf
31
+ Requires-Dist: Pillow
32
+ Dynamic: license-file
33
+
34
+ # eTiKeT Sync Agent - QUAlibrate Connector
35
+
36
+ Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
37
+
38
+ ## Installation
39
+
40
+ Install the QUAlibrate connector using the eTiKeT Sync SDK:
41
+
42
+ ```python
43
+ from etiket_sdk.sync import Connectors
44
+
45
+ # Install the latest version
46
+ Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
47
+ ```
48
+
49
+ The package is automatically discovered by `etiket_sync_agent` through the entry-point system. Once installed, you can verify with:
50
+
51
+ ```python
52
+ # List installed connectors
53
+ print(Connectors.list())
54
+
55
+ # Get details for the QUAlibrate connector
56
+ connector = Connectors.get("etiket_sync_agent_qualibrate")
57
+ print(connector)
58
+ ```
59
+
60
+ ### Updating the Connector
61
+
62
+ ```python
63
+ # Update to the latest version
64
+ Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
65
+ ```
66
+
67
+ ## What Gets Synchronized
68
+
69
+ Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
70
+
71
+ | QUAlibrate Data | eTiKeT Field | Description |
72
+ |-----------------|--------------|-------------|
73
+ | Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
74
+ | `metadata.name` (or folder name) | `name` | Name of the dataset |
75
+ | `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
76
+ | `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
77
+ | `metadata.description` | `description` | Snapshot description |
78
+ | `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
79
+ | `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
80
+
81
+ ### Data Processing
82
+
83
+ - **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
84
+ - **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
85
+ - **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
86
+ - **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
87
+ - **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
88
+
89
+ ---
90
+
91
+ ## Configuration
92
+
93
+ The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
94
+
95
+ | Field | Type | Required | Description |
96
+ |-------|------|----------|-------------|
97
+ | `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
98
+ | `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
99
+
100
+ A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
101
+
102
+ The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
103
+
104
+ ### Example Configuration
105
+
106
+ Example using the `etiket-sdk` package:
107
+
108
+ ```python
109
+ from etiket_sdk.sync import SyncSources
110
+
111
+ SyncSources.create(
112
+ name="my_qualibrate_source",
113
+ connector_identifier="etiket_sync_agent_qualibrate",
114
+ config_data={
115
+ "data_directory": "~/.qualibrate/user_storage",
116
+ "is_server_folder": False
117
+ },
118
+ default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
119
+ )
120
+ ```
121
+
122
+ ## Live Sync
123
+
124
+ Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
125
+
126
+ ## Requirements
127
+
128
+ - Python >= 3.10
129
+ - numpy
130
+ - xarray
131
+ - h5netcdf
132
+ - Pillow
133
+
134
+ ## License
135
+
136
+ Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
@@ -0,0 +1,103 @@
1
+ # eTiKeT Sync Agent - QUAlibrate Connector
2
+
3
+ Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
4
+
5
+ ## Installation
6
+
7
+ Install the QUAlibrate connector using the eTiKeT Sync SDK:
8
+
9
+ ```python
10
+ from etiket_sdk.sync import Connectors
11
+
12
+ # Install the latest version
13
+ Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
14
+ ```
15
+
16
+ The package is automatically discovered by `etiket_sync_agent` through the entry-point system. Once installed, you can verify with:
17
+
18
+ ```python
19
+ # List installed connectors
20
+ print(Connectors.list())
21
+
22
+ # Get details for the QUAlibrate connector
23
+ connector = Connectors.get("etiket_sync_agent_qualibrate")
24
+ print(connector)
25
+ ```
26
+
27
+ ### Updating the Connector
28
+
29
+ ```python
30
+ # Update to the latest version
31
+ Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
32
+ ```
33
+
34
+ ## What Gets Synchronized
35
+
36
+ Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
37
+
38
+ | QUAlibrate Data | eTiKeT Field | Description |
39
+ |-----------------|--------------|-------------|
40
+ | Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
41
+ | `metadata.name` (or folder name) | `name` | Name of the dataset |
42
+ | `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
43
+ | `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
44
+ | `metadata.description` | `description` | Snapshot description |
45
+ | `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
46
+ | `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
47
+
48
+ ### Data Processing
49
+
50
+ - **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
51
+ - **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
52
+ - **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
53
+ - **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
54
+ - **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
55
+
56
+ ---
57
+
58
+ ## Configuration
59
+
60
+ The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
61
+
62
+ | Field | Type | Required | Description |
63
+ |-------|------|----------|-------------|
64
+ | `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
65
+ | `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
66
+
67
+ A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
68
+
69
+ The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
70
+
71
+ ### Example Configuration
72
+
73
+ Example using the `etiket-sdk` package:
74
+
75
+ ```python
76
+ from etiket_sdk.sync import SyncSources
77
+
78
+ SyncSources.create(
79
+ name="my_qualibrate_source",
80
+ connector_identifier="etiket_sync_agent_qualibrate",
81
+ config_data={
82
+ "data_directory": "~/.qualibrate/user_storage",
83
+ "is_server_folder": False
84
+ },
85
+ default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
86
+ )
87
+ ```
88
+
89
+ ## Live Sync
90
+
91
+ Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
92
+
93
+ ## Requirements
94
+
95
+ - Python >= 3.10
96
+ - numpy
97
+ - xarray
98
+ - h5netcdf
99
+ - Pillow
100
+
101
+ ## License
102
+
103
+ Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
@@ -0,0 +1,5 @@
1
+ from .qualibrate_sync_class import QualibrateSync
2
+ from .qualibrate_config_class import QualibrateConfigData
3
+
4
+ __all__ = ["QualibrateSync", "QualibrateConfigData"]
5
+ __version__ = "0.0.1"
@@ -0,0 +1,226 @@
1
+ """Vendored, dependency-light copy of qualibrate's node data loader.
2
+
3
+ We only need qualibrate's reference-resolution logic (turning the
4
+ ``./arrays.npz#a.b.c`` style references in ``data.json`` back into real
5
+ numpy / xarray / image objects). Installing the full ``qualibrate`` package to
6
+ get this drags in the whole web stack (fastapi, uvicorn, sqlalchemy, ...), which
7
+ conflicts with the sync-agent environment, so the relevant pieces are vendored
8
+ here instead.
9
+
10
+ Ported from ``qualibrate.core.utils.node.{content,path_solver,loaders}``
11
+ (qualibrate 1.4.0). Only ``read_node_data`` and its dependencies are included;
12
+ the parameter/pydantic machinery from the original ``content.py`` is omitted.
13
+
14
+ Runtime deps: numpy (required), xarray + Pillow (optional, used if installed).
15
+ """
16
+
17
+ import json
18
+ import logging
19
+ from abc import ABC, abstractmethod
20
+ from importlib.util import find_spec
21
+ from itertools import chain
22
+ from os import PathLike
23
+ from pathlib import Path
24
+ from typing import Any, Optional, Sequence
25
+
26
+ import numpy as np
27
+ from numpy.lib.npyio import NpzFile
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ __all__ = ["read_node_data"]
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Loaders (extension -> object)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ class BaseLoader(ABC):
39
+ file_extensions: tuple[str, ...]
40
+
41
+ @classmethod
42
+ def is_loader_support_extension(cls, extension: str) -> bool:
43
+ return extension.lower() in cls.file_extensions
44
+
45
+ @classmethod
46
+ def validate_file_exists(cls, file_path: Path) -> None:
47
+ if not file_path.is_file():
48
+ raise FileNotFoundError(f"Can't load file '{file_path}' by loader {cls.__name__}")
49
+
50
+ @abstractmethod
51
+ def load(self, path: Path, **kwargs: Any) -> Any:
52
+ raise NotImplementedError
53
+
54
+
55
+ class JSONLoader(BaseLoader):
56
+ file_extensions = (".json",)
57
+
58
+ def load(self, path: Path, **kwargs: Any) -> Any:
59
+ self.__class__.validate_file_exists(path)
60
+ with open(path) as f:
61
+ return json.load(f)
62
+
63
+
64
+ class NumpyArrayLoader(BaseLoader):
65
+ file_extensions = (".npy", ".npz")
66
+
67
+ def __init__(self) -> None:
68
+ self.filepath_to_array: dict[Path, Any] = {}
69
+
70
+ def load(self, path: Path, **kwargs: Any) -> Any:
71
+ if path in self.filepath_to_array:
72
+ file_content = self.filepath_to_array[path]
73
+ else:
74
+ self.__class__.validate_file_exists(path)
75
+ file_content = np.load(path)
76
+ self.filepath_to_array[path] = file_content
77
+ subref = kwargs.get("subref")
78
+ if subref is None:
79
+ return file_content
80
+ if not isinstance(file_content, NpzFile):
81
+ raise ValueError(f"Loaded file {path} is not representation of multiple NumPy arrays")
82
+ return file_content.get(subref)
83
+
84
+
85
+ class XarrayLoader(BaseLoader):
86
+ file_extensions: tuple[str, ...] = (".h5",)
87
+
88
+ def load(self, path: Path, **kwargs: Any) -> Any:
89
+ # Faithful to qualibrate: opens the dataset and ignores any "#group"
90
+ # subref (the default save mode writes one file per dataset).
91
+ if find_spec("xarray") is None:
92
+ return None
93
+ from xarray import open_dataset
94
+
95
+ self.__class__.validate_file_exists(path)
96
+ return open_dataset(path)
97
+
98
+
99
+ class ImageLoader(BaseLoader):
100
+ file_extensions: tuple[str, ...] = (".png",)
101
+
102
+ def load(self, path: Path, **kwargs: Any) -> Any:
103
+ if find_spec("PIL") is None:
104
+ return None
105
+ from PIL import Image
106
+
107
+ self.__class__.validate_file_exists(path)
108
+ return Image.open(path)
109
+
110
+
111
+ # QuamLoader is intentionally omitted: quam state is referenced from node.json,
112
+ # not from data.json, so read_node_data never needs it.
113
+ DEFAULT_LOADERS: list[type[BaseLoader]] = [
114
+ JSONLoader,
115
+ NumpyArrayLoader,
116
+ ImageLoader,
117
+ XarrayLoader,
118
+ ]
119
+
120
+ SUPPORTED_EXTENSIONS = set(chain.from_iterable(loader.file_extensions for loader in DEFAULT_LOADERS))
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Path helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def get_data_filepath(node_path: Path) -> Path:
128
+ return node_path / "data.json"
129
+
130
+
131
+ def resolve_and_check_relative(base_path: Path, subpath: "PathLike[str]") -> Path:
132
+ """Build a full path from base + subpath; raise if it escapes base_path."""
133
+ full = (base_path / Path(subpath)).resolve()
134
+ base_path_resolved = base_path.resolve()
135
+ if not full.is_relative_to(base_path_resolved):
136
+ raise FileNotFoundError(f"Subpath {subpath} isn't relative to base {base_path}.")
137
+ return full
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Reference resolution
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _read_raw_node_file(node_filepath: Path) -> dict[str, Any]:
145
+ try:
146
+ with node_filepath.open("r") as f:
147
+ return dict(json.load(f))
148
+ except json.JSONDecodeError as ex:
149
+ logger.exception("Can't read json by path %s", node_filepath, exc_info=ex)
150
+ raise
151
+
152
+
153
+ def _get_filename_and_subreference(filepath: "str | Path") -> tuple[Path, Optional[str]]:
154
+ """``"./arrays.npz#a.b.c"`` -> ``(Path("./arrays.npz"), "a.b.c")``."""
155
+ filepath = Path(filepath)
156
+ name, *subref = filepath.name.split("#")
157
+ clear_filepath = filepath.with_name(name)
158
+ return clear_filepath, "#".join(subref) if subref else None
159
+
160
+
161
+ def _check_supported_reference(filename: Any, supported_extensions: set[str]) -> bool:
162
+ if not isinstance(filename, str) or "." not in filename:
163
+ return False
164
+ filepath, _ = _get_filename_and_subreference(filename)
165
+ return filepath.suffix in supported_extensions
166
+
167
+
168
+ def _read_reference(reference: str, loaders: Sequence[BaseLoader], node_dir: Path) -> Any:
169
+ filepath, subreference = _get_filename_and_subreference(reference)
170
+ try:
171
+ filepath = resolve_and_check_relative(node_dir, filepath)
172
+ except FileNotFoundError as ex:
173
+ logger.exception("File %s can't be located inside node directory.", filepath, exc_info=ex)
174
+ return reference
175
+ supported_loaders = [ldr for ldr in loaders if ldr.is_loader_support_extension(filepath.suffix)]
176
+ for loader in supported_loaders:
177
+ try:
178
+ result = loader.load(filepath, subref=subreference)
179
+ if result is None:
180
+ continue
181
+ return result
182
+ except Exception as ex:
183
+ logger.exception("Can't load reference (%s) by loader %s", reference, loader, exc_info=ex)
184
+ logger.warning("Reference %s was not loaded. Kept reference value.", reference)
185
+ return reference
186
+
187
+
188
+ def _resolve_references(
189
+ raw_data: dict[str, Any],
190
+ loaders: Sequence[BaseLoader],
191
+ supported_extensions: set[str],
192
+ node_dir: Path,
193
+ ) -> None:
194
+ for key, value in raw_data.items():
195
+ if isinstance(value, dict):
196
+ _resolve_references(value, loaders, supported_extensions, node_dir)
197
+ raw_data[key] = value
198
+ if not _check_supported_reference(value, supported_extensions):
199
+ continue
200
+ raw_data[key] = _read_reference(value, loaders, node_dir)
201
+
202
+
203
+ def read_node_data(
204
+ node_dir: Path,
205
+ node_id: int,
206
+ base_path: Path,
207
+ custom_loaders: Optional[Sequence[type[BaseLoader]]] = None,
208
+ ) -> Optional[dict[str, Any]]:
209
+ """Read ``data.json`` and resolve its references into real objects.
210
+
211
+ Returns the results dict (with numpy/xarray/image objects in place of the
212
+ reference strings), or None if there is no data.json.
213
+ """
214
+ data_filepath = get_data_filepath(node_dir)
215
+ if not data_filepath.is_file():
216
+ logger.error("Data file for node %s wasn't found in %s", node_id, base_path)
217
+ return None
218
+ results = _read_raw_node_file(data_filepath)
219
+ loader_classes: list[type[BaseLoader]] = [*custom_loaders] if custom_loaders is not None else []
220
+ loader_classes.extend(DEFAULT_LOADERS)
221
+ loaders_instances = [loader() for loader in loader_classes]
222
+ supported_extensions = set(
223
+ chain.from_iterable(loader.file_extensions for loader in DEFAULT_LOADERS)
224
+ ) | SUPPORTED_EXTENSIONS
225
+ _resolve_references(results, loaders_instances, supported_extensions, node_dir)
226
+ return results
@@ -0,0 +1,74 @@
1
+ import dataclasses
2
+ import pathlib
3
+ import etiket_sync_agent_qualibrate
4
+
5
+ from typing import Optional
6
+
7
+ # Imports that you will likely need
8
+ from etiket_sync_agent.crud.sync_sources import crud_sync_sources, SyncSources
9
+ from etiket_sync_agent.db import get_db_session_context
10
+
11
+ @dataclasses.dataclass
12
+ class QualibrateConfigData:
13
+ """Template config dataclass for a sync connector.
14
+
15
+ Add your configuration fields here and implement the validate method
16
+ to perform sanity checks and cross-check with existing sources.
17
+ """
18
+ data_directory: pathlib.Path
19
+ is_server_folder : bool
20
+
21
+ def __post_init__(self):
22
+ # Ensure that data_directory is a pathlib.Path object
23
+ if not isinstance(self.data_directory, pathlib.Path):
24
+ self.data_directory = pathlib.Path(self.data_directory)
25
+
26
+ async def validate(self, current_sync_source: Optional[SyncSources] = None):
27
+ """
28
+ Validates the qualibrate configuration.
29
+
30
+ Checks:
31
+ 1. If the data_directory exists and is a directory.
32
+ 2. If the data_directory conflicts with an existing qualibrate sync source
33
+ (i.e., it's identical, a subdirectory, or a parent directory).
34
+
35
+ Raises:
36
+ ValueError: If any validation check fails.
37
+
38
+ Returns:
39
+ True if all checks pass.
40
+ """
41
+ try:
42
+ abs_root_dir = self.data_directory.expanduser().resolve(strict=True)
43
+ except FileNotFoundError:
44
+ raise ValueError(f"The specified root directory does not exist: {self.data_directory}")
45
+
46
+ # check if the root directory exists and is a directory.
47
+ if not abs_root_dir.is_dir():
48
+ raise ValueError(f"The specified path is not a directory: {abs_root_dir}")
49
+
50
+ # check if the folder is not yet added/is part of a folder that is already added.
51
+ async with get_db_session_context() as session:
52
+ sync_sources = await crud_sync_sources.list_sync_sources(session)
53
+ for sync_source in sync_sources:
54
+ if sync_source.connector == etiket_sync_agent_qualibrate.__name__ and (current_sync_source is None or sync_source.id != current_sync_source.id):
55
+ # Assuming config_data stores the path as a string and is always present/valid.
56
+ existing_path_str = sync_source.config_data['data_directory']
57
+ existing_path = pathlib.Path(existing_path_str).expanduser().resolve()
58
+
59
+ # Check for conflicts (using samefile for case-insensitive filesystem support)
60
+ try:
61
+ is_same = abs_root_dir.samefile(existing_path)
62
+ except FileNotFoundError:
63
+ is_same = False # existing path was deleted
64
+
65
+ if is_same:
66
+ raise ValueError(f"The directory '{abs_root_dir}' is already added as sync source '{sync_source.name}'.")
67
+ # Check if the new path is inside an existing path
68
+ if abs_root_dir.is_relative_to(existing_path):
69
+ raise ValueError(f"The directory '{abs_root_dir}' is inside the directory '{existing_path}' added by sync source '{sync_source.name}'.")
70
+ # Check if an existing path is inside the new path
71
+ if existing_path.is_relative_to(abs_root_dir):
72
+ raise ValueError(f"The directory '{existing_path}' added by sync source '{sync_source.name}' is inside the specified directory '{abs_root_dir}'.")
73
+
74
+ return True
@@ -0,0 +1,266 @@
1
+ import json
2
+ import typing
3
+ import datetime
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import xarray as xr
8
+
9
+ from etiket_sync_agent.connectors.sync_source_abstract import SyncSourceFileBase, ScopeRequirement
10
+ from etiket_sync_agent.sync.sync_records.manager import SyncRecordManager
11
+ from etiket_sync_agent.schemas import SyncItemSchema
12
+ from etiket_sync_agent.sync.sync_utilities import sync_utilities, dataset_info, file_info, FileType
13
+
14
+ # vendored qualibrate loader (resolves `./file.npz#a.b.c` references) — avoids
15
+ # depending on the full qualibrate package (which drags in fastapi/uvicorn/etc.)
16
+ from ._qualibrate_loader import read_node_data
17
+
18
+ from .qualibrate_config_class import QualibrateConfigData
19
+
20
+ FILE_GENERATOR = "QUAlibrate"
21
+
22
+
23
+ class QualibrateSync(SyncSourceFileBase):
24
+ sync_agent_name: typing.ClassVar[str] = "QUAlibrate"
25
+ config_data_class: typing.ClassVar[typing.Type[QualibrateConfigData]] = QualibrateConfigData
26
+ scope_requirement: typing.ClassVar[ScopeRequirement] = ScopeRequirement.REQUIRED
27
+ live_sync_implemented: typing.ClassVar[bool] = False
28
+ supports_scope_mapping = False
29
+ level: typing.ClassVar[int] = 2
30
+ has_owner: typing.ClassVar[bool] = True
31
+ is_single_file = False
32
+
33
+ @staticmethod
34
+ def rootPath(config_data: QualibrateConfigData) -> Path:
35
+ return Path(config_data.data_directory)
36
+
37
+ @staticmethod
38
+ async def checkLiveDataset(config_data: QualibrateConfigData, sync_item: SyncItemSchema, max_priority: bool) -> bool:
39
+ """
40
+ Always false since we don't have a mechanism for live datasets.
41
+ """
42
+ return False
43
+
44
+ @staticmethod
45
+ async def syncDatasetNormal(config_data: QualibrateConfigData, sync_item: SyncItemSchema, sync_record: SyncRecordManager):
46
+ root = Path(config_data.data_directory)
47
+ node_dir = (root / sync_item.dataIdentifier).resolve()
48
+
49
+ # scope_requirement is REQUIRED, so a scope must be present.
50
+ scope_uuid = sync_item.scopeUUID
51
+ if scope_uuid is None:
52
+ raise ValueError("A scope is required for the qualibrate sync source.")
53
+
54
+ # --- 1. Collect dataset information (node.json) ------------------------
55
+ node_json_path = node_dir / "node.json"
56
+ if not node_json_path.is_file():
57
+ sync_record.add_log(f"No node.json in {node_dir}; not a qualibrate snapshot (yet), skipping.")
58
+ return
59
+
60
+ with sync_record.task("Reading qualibrate snapshot (node.json)"):
61
+ node_info = json.loads(node_json_path.read_text())
62
+
63
+ metadata = node_info.get("metadata", {}) or {}
64
+ snapshot_id = node_info.get("id")
65
+ exec_type = metadata.get("type_of_execution", "node") # "node" | "workflow"
66
+ created = _parse_dt(node_info.get("created_at") or metadata.get("run_start"))
67
+
68
+ # --- 2. Create / update the dataset record -----------------------------
69
+ ds_info = dataset_info(
70
+ name=metadata.get("name") or node_dir.name,
71
+ datasetUUID=sync_item.datasetUUID,
72
+ scopeUUID=scope_uuid,
73
+ created=created,
74
+ alt_uid=node_dir.name,
75
+ description=metadata.get("description"),
76
+ keywords=[exec_type],
77
+ attributes=_build_attributes(node_info, metadata, exec_type),
78
+ )
79
+ await sync_utilities.create_or_update_dataset(False, sync_item, ds_info, sync_record)
80
+
81
+ # --- 3. Upload every raw file ------------------------------------------
82
+ with sync_record.task("Uploading raw snapshot files"):
83
+ await _upload_raw_files(node_dir, sync_item, created, sync_record)
84
+
85
+ # --- 4. Build the raw-data netCDF for the plotting system -------------
86
+ # data.json -> load all arrays -> extract the relevant data ->
87
+ # arrays as vars/coords -> merge into one struct (single netCDF).
88
+ with sync_record.task("Loading results via qualibrate loaders"):
89
+ try:
90
+ results = read_node_data(node_dir, snapshot_id or 0, root) or {}
91
+ except Exception as e: # never let a bad results tree kill the sync
92
+ sync_record.add_error("Failed to load results via qualibrate loaders", e)
93
+ results = {}
94
+
95
+ datasets = _split_results(results)
96
+
97
+ if datasets:
98
+ with sync_record.task("Combining raw data into a single netCDF dataset"):
99
+ combined = _combine_raw(datasets, sync_record)
100
+ if combined is not None:
101
+ await sync_utilities.upload_xarray(
102
+ combined, sync_item,
103
+ file_info(name="raw_data", fileName="raw_data.h5",
104
+ created=created, fileType=FileType.HDF5_NETCDF,
105
+ file_generator=FILE_GENERATOR),
106
+ sync_record,
107
+ )
108
+ else:
109
+ sync_record.add_log("No array/xarray raw data found (expected for workflows).")
110
+
111
+ sync_record.add_log(f"Synchronized qualibrate {exec_type} snapshot {snapshot_id}.")
112
+
113
+ @staticmethod
114
+ async def syncDatasetLive(config_data: QualibrateConfigData, sync_item: SyncItemSchema, sync_record: SyncRecordManager):
115
+ # Implement the live synchronization flow if applicable.
116
+ raise NotImplementedError
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Helpers
121
+ # ---------------------------------------------------------------------------
122
+
123
+ def _filetype_for_suffix(path: Path) -> FileType:
124
+ return {
125
+ ".h5": FileType.HDF5_NETCDF,
126
+ ".nc": FileType.HDF5_NETCDF,
127
+ ".hdf5": FileType.HDF5_NETCDF,
128
+ ".json": FileType.JSON,
129
+ ".npy": FileType.NDARRAY,
130
+ ".npz": FileType.NDARRAY,
131
+ ".txt": FileType.TEXT,
132
+ }.get(path.suffix.lower(), FileType.UNKNOWN)
133
+
134
+
135
+ async def _upload_raw_files(
136
+ node_dir: Path,
137
+ sync_item: SyncItemSchema,
138
+ created: datetime.datetime,
139
+ sync_record: SyncRecordManager,
140
+ ) -> None:
141
+ """Upload every file in the snapshot folder verbatim, under a "raw/" name.
142
+
143
+ Preserves the original bytes and folder structure so the complete snapshot
144
+ is always captured, even if the curated combine/JSON steps drop something.
145
+ """
146
+ for path in sorted(node_dir.rglob("*")):
147
+ if not path.is_file() or path.name.startswith("."):
148
+ continue
149
+ rel = path.relative_to(node_dir).as_posix()
150
+ await sync_utilities.upload_file(
151
+ str(path), sync_item,
152
+ file_info(name=f"raw/{rel}", fileName=path.name,
153
+ created=created, fileType=_filetype_for_suffix(path),
154
+ file_generator=FILE_GENERATOR),
155
+ sync_record,
156
+ )
157
+
158
+
159
+ def _parse_dt(value: typing.Optional[str]) -> datetime.datetime:
160
+ if value:
161
+ try:
162
+ return datetime.datetime.fromisoformat(value)
163
+ except ValueError:
164
+ pass
165
+ return datetime.datetime.now().astimezone()
166
+
167
+
168
+ def _build_attributes(node_info: dict, metadata: dict, exec_type: str) -> dict:
169
+ """Small, searchable scalar metadata. Values are stringified by dataset_info.
170
+ """
171
+ attrs: dict = {"type_of_execution": exec_type}
172
+ for key in ("status"):
173
+ if metadata.get(key) is not None:
174
+ attrs[key] = metadata[key]
175
+ return attrs
176
+
177
+
178
+ def _split_results(tree: typing.Mapping, prefix: str = "") -> dict:
179
+ """Walk the results tree and turn array-like leaves into xr.Datasets.
180
+
181
+ - xr.Dataset / xr.DataArray values are taken as-is.
182
+ - A dict whose direct children include numpy arrays is treated as one
183
+ measurement: by convention the FIRST array is the independent x-axis
184
+ (a coordinate) and the remaining arrays are values measured over it.
185
+ Everything else (scalars, figures, ...) is ignored here because it is
186
+ already captured by the verbatim raw-file upload.
187
+
188
+ Returns {dotted_path: xr.Dataset}.
189
+ """
190
+ datasets: dict = {}
191
+ array_items = [(k, v) for k, v in tree.items() if isinstance(v, np.ndarray)]
192
+ for key, value in tree.items():
193
+ path = f"{prefix}{key}"
194
+ if isinstance(value, xr.Dataset):
195
+ datasets[path] = value
196
+ elif isinstance(value, xr.DataArray):
197
+ datasets[path] = value.to_dataset(name=str(key))
198
+ elif isinstance(value, dict):
199
+ datasets.update(_split_results(value, prefix=f"{path}."))
200
+ # numpy arrays are handled as a sibling group below
201
+ if array_items:
202
+ group_path = prefix.rstrip(".") or "raw_data"
203
+ datasets[group_path] = _arrays_to_dataset(array_items)
204
+ return datasets
205
+
206
+
207
+ def _arrays_to_dataset(array_items: list) -> xr.Dataset:
208
+ """Build an xr.Dataset from a group of sibling numpy arrays.
209
+
210
+ Convention (npz format): the leading 1-D arrays are the coordinate axes in
211
+ order (x, y, z, ...) and the remaining arrays are the measured values over
212
+ the grid those axes span. The number of axes is taken from the highest-rank
213
+ array (1 for a line sweep, 2 for a chevron map, ...). Falls back to
214
+ standalone variables with independent dims if the shapes don't fit.
215
+ """
216
+ arrays = [(name, np.asarray(v)) for name, v in array_items if v is not None]
217
+ if not arrays:
218
+ return xr.Dataset()
219
+
220
+ n_axes = max(v.ndim for _, v in arrays) # x, y, z, ...
221
+ axes, data = arrays[:n_axes], arrays[n_axes:]
222
+ expected_shape = tuple(v.shape[0] for _, v in axes)
223
+ axes_ok = len(axes) == n_axes and all(v.ndim == 1 for _, v in axes)
224
+ data_ok = bool(data) and all(v.shape == expected_shape for _, v in data)
225
+
226
+ if axes_ok and data_ok:
227
+ axis_names = [n for n, _ in axes]
228
+ return xr.Dataset(
229
+ {n: (axis_names, v) for n, v in data},
230
+ coords={n: v for n, v in axes},
231
+ )
232
+
233
+ return xr.Dataset({n: ([f"{n}.dim_{i}" for i in range(v.ndim)], v) for n, v in arrays})
234
+
235
+
236
+ def _namespace_all(path: str, ds: xr.Dataset) -> xr.Dataset:
237
+ names = set(ds.dims) | set(ds.coords) | set(ds.data_vars)
238
+ return ds.rename({n: f"{path}.{n}" for n in names})
239
+
240
+
241
+ def _combine_raw(datasets: dict, sync_record: SyncRecordManager) -> typing.Optional[xr.Dataset]:
242
+ """Combine the per-measurement datasets into one flat xr.Dataset.
243
+
244
+ Strategy ladder:
245
+ 1. prefix only data_vars by their results-path, keep coords shared, merge
246
+ with join="exact" so a sweep axis shared by parallel measurements stays
247
+ shared, but mismatched same-named axes raise instead of silently
248
+ outer-joining (union + NaN padding);
249
+ 2. on conflict, fully namespace every dataset (no shared axes, but lossless
250
+ and collision-proof).
251
+ """
252
+ prepared = [ds.rename({v: f"{path}.{v}" for v in ds.data_vars}) for path, ds in datasets.items()]
253
+
254
+ if not prepared:
255
+ return None
256
+ if len(prepared) == 1:
257
+ return prepared[0]
258
+
259
+ try:
260
+ return typing.cast(xr.Dataset, xr.merge(prepared, join="exact", compat="no_conflicts", combine_attrs="drop_conflicts"))
261
+ except Exception as e:
262
+ sync_record.add_log(f"Shared-coordinate merge failed ({e}); namespacing fully.")
263
+ fully = [_namespace_all(path, ds) for path, ds in datasets.items()]
264
+ return typing.cast(xr.Dataset, xr.merge(fully, combine_attrs="drop_conflicts"))
265
+
266
+
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: etiket_sync_agent_qualibrate
3
+ Version: 0.0.1
4
+ Summary: QUAlibrate connector 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,connector,qualibrate,quantum
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: numpy
29
+ Requires-Dist: xarray
30
+ Requires-Dist: h5netcdf
31
+ Requires-Dist: Pillow
32
+ Dynamic: license-file
33
+
34
+ # eTiKeT Sync Agent - QUAlibrate Connector
35
+
36
+ Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
37
+
38
+ ## Installation
39
+
40
+ Install the QUAlibrate connector using the eTiKeT Sync SDK:
41
+
42
+ ```python
43
+ from etiket_sdk.sync import Connectors
44
+
45
+ # Install the latest version
46
+ Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
47
+ ```
48
+
49
+ The package is automatically discovered by `etiket_sync_agent` through the entry-point system. Once installed, you can verify with:
50
+
51
+ ```python
52
+ # List installed connectors
53
+ print(Connectors.list())
54
+
55
+ # Get details for the QUAlibrate connector
56
+ connector = Connectors.get("etiket_sync_agent_qualibrate")
57
+ print(connector)
58
+ ```
59
+
60
+ ### Updating the Connector
61
+
62
+ ```python
63
+ # Update to the latest version
64
+ Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
65
+ ```
66
+
67
+ ## What Gets Synchronized
68
+
69
+ Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
70
+
71
+ | QUAlibrate Data | eTiKeT Field | Description |
72
+ |-----------------|--------------|-------------|
73
+ | Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
74
+ | `metadata.name` (or folder name) | `name` | Name of the dataset |
75
+ | `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
76
+ | `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
77
+ | `metadata.description` | `description` | Snapshot description |
78
+ | `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
79
+ | `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
80
+
81
+ ### Data Processing
82
+
83
+ - **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
84
+ - **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
85
+ - **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
86
+ - **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
87
+ - **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
88
+
89
+ ---
90
+
91
+ ## Configuration
92
+
93
+ The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
94
+
95
+ | Field | Type | Required | Description |
96
+ |-------|------|----------|-------------|
97
+ | `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
98
+ | `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
99
+
100
+ A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
101
+
102
+ The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
103
+
104
+ ### Example Configuration
105
+
106
+ Example using the `etiket-sdk` package:
107
+
108
+ ```python
109
+ from etiket_sdk.sync import SyncSources
110
+
111
+ SyncSources.create(
112
+ name="my_qualibrate_source",
113
+ connector_identifier="etiket_sync_agent_qualibrate",
114
+ config_data={
115
+ "data_directory": "~/.qualibrate/user_storage",
116
+ "is_server_folder": False
117
+ },
118
+ default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
119
+ )
120
+ ```
121
+
122
+ ## Live Sync
123
+
124
+ Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
125
+
126
+ ## Requirements
127
+
128
+ - Python >= 3.10
129
+ - numpy
130
+ - xarray
131
+ - h5netcdf
132
+ - Pillow
133
+
134
+ ## License
135
+
136
+ Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
@@ -0,0 +1,13 @@
1
+ LICENCE
2
+ README.md
3
+ pyproject.toml
4
+ etiket_sync_agent_qualibrate/__init__.py
5
+ etiket_sync_agent_qualibrate/_qualibrate_loader.py
6
+ etiket_sync_agent_qualibrate/qualibrate_config_class.py
7
+ etiket_sync_agent_qualibrate/qualibrate_sync_class.py
8
+ etiket_sync_agent_qualibrate.egg-info/PKG-INFO
9
+ etiket_sync_agent_qualibrate.egg-info/SOURCES.txt
10
+ etiket_sync_agent_qualibrate.egg-info/dependency_links.txt
11
+ etiket_sync_agent_qualibrate.egg-info/entry_points.txt
12
+ etiket_sync_agent_qualibrate.egg-info/requires.txt
13
+ etiket_sync_agent_qualibrate.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [etiket_sync_agent.connectors]
2
+ qualibrate_sync_agent = etiket_sync_agent_qualibrate:QualibrateSync
@@ -0,0 +1,5 @@
1
+ etiket_sync_agent>=0.3.0b1
2
+ numpy
3
+ xarray
4
+ h5netcdf
5
+ Pillow
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69.0.0", "setuptools-scm"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "etiket_sync_agent_qualibrate"
7
+ description = "QUAlibrate connector for eTiKeT sync agent"
8
+ readme = "README.md"
9
+ authors = [{name = "QHarbor team"}]
10
+ license = "LicenseRef-Proprietary"
11
+ license-files = ["LICENCE"]
12
+ requires-python = ">=3.10"
13
+ dynamic = ["version"]
14
+ keywords = [
15
+ "etiket",
16
+ "sync",
17
+ "connector",
18
+ "qualibrate",
19
+ "quantum",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: Science/Research",
25
+ "Operating System :: POSIX :: Linux",
26
+ "Operating System :: MacOS :: MacOS X",
27
+ "Operating System :: Microsoft :: Windows",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
34
+ "Topic :: Scientific/Engineering",
35
+ "Topic :: Software Development :: Libraries :: Python Modules",
36
+ ]
37
+ dependencies = [
38
+ "etiket_sync_agent>=0.3.0b1",
39
+ "numpy",
40
+ "xarray",
41
+ "h5netcdf",
42
+ "Pillow",
43
+ ]
44
+
45
+ [project.entry-points."etiket_sync_agent.connectors"]
46
+ qualibrate_sync_agent = "etiket_sync_agent_qualibrate:QualibrateSync"
47
+
48
+ [project.urls]
49
+ Homepage = "https://qharbor.nl"
50
+ Documentation = "https://docs.qharbor.nl"
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["."]
57
+ include = ["etiket_sync_agent_qualibrate*"]
58
+
59
+ [tool.setuptools.dynamic]
60
+ version = {attr = "etiket_sync_agent_qualibrate.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+