vqflow 0.1.0__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.
vqflow/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """VQFlow's Python interface for versioned dataset exports."""
2
+
3
+ from .builder import DatasetExportBuilder
4
+ from .client import VQFlow
5
+ from .exceptions import (
6
+ AmbiguousResourceError, AuthenticationError, ConfigurationError, DownloadError,
7
+ ExportConflictError, ExportCreationUncertainError, ExportFailedError,
8
+ ExportTimeoutError, NotFoundError, PermissionDeniedError, ProtocolError,
9
+ TransportError, VQFlowError,
10
+ )
11
+ from .models import ExportFormat, QAStatus, ReviewStatus
12
+ from .resources import Dataset, Project, Workspace
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "VQFlow", "DatasetExportBuilder", "Workspace", "Project", "Dataset",
18
+ "ExportFormat", "ReviewStatus", "QAStatus", "VQFlowError", "ConfigurationError",
19
+ "AuthenticationError", "PermissionDeniedError", "NotFoundError",
20
+ "AmbiguousResourceError", "ExportConflictError", "ExportFailedError",
21
+ "ExportTimeoutError", "ExportCreationUncertainError", "TransportError",
22
+ "ProtocolError", "DownloadError", "__version__",
23
+ ]
vqflow/builder.py ADDED
@@ -0,0 +1,103 @@
1
+ """Fluent configuration for dataset ZIP exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .client import VQFlow
8
+ from .exceptions import ConfigurationError
9
+ from .models import ExportFormat, QAStatus, ReviewStatus, export_format, qa_status, required_name, review_status
10
+ from .resources import UNSPECIFIED, _Unspecified
11
+
12
+
13
+ class DatasetExportBuilder:
14
+ """Configure an export; only export() and exportCurated() perform work."""
15
+
16
+ def __init__(self, client: VQFlow) -> None:
17
+ if not isinstance(client, VQFlow):
18
+ raise ConfigurationError("Supply a VQFlow client to DatasetExportBuilder.")
19
+ self._client = client
20
+ self._workspace: str | None = None
21
+ self._project: str | None = None
22
+ self._dataset: str | None = None
23
+ self._dataset_version: str | None | _Unspecified = UNSPECIFIED
24
+ self._format: ExportFormat | None = None
25
+ self._version: str | None = None
26
+ self._destination: str | Path | None = None
27
+ self._review: ReviewStatus | None = None
28
+ self._qa: QAStatus | None = None
29
+ self._export_id: int | None = None
30
+
31
+ def withWorkspace(self, name: str) -> DatasetExportBuilder:
32
+ self._workspace = required_name(name, "Workspace name")
33
+ return self
34
+
35
+ def withProject(self, name: str) -> DatasetExportBuilder:
36
+ self._project = required_name(name, "Project name")
37
+ return self
38
+
39
+ def withDataset(self, name: str) -> DatasetExportBuilder:
40
+ self._dataset = required_name(name, "Dataset name")
41
+ return self
42
+
43
+ def withDatasetVersion(self, version: str | None) -> DatasetExportBuilder:
44
+ """Disambiguate the source dataset; None selects its unversioned record."""
45
+ self._dataset_version = None if version is None else required_name(version, "Dataset version")
46
+ return self
47
+
48
+ def withFormat(self, format: ExportFormat | str) -> DatasetExportBuilder:
49
+ self._format = export_format(format)
50
+ return self
51
+
52
+ def withVersion(self, version: str) -> DatasetExportBuilder:
53
+ self._version = required_name(version, "Export version")
54
+ return self
55
+
56
+ def withExportId(self, export_id: int) -> DatasetExportBuilder:
57
+ """Download one exact stored export, preserving its original selection."""
58
+ if type(export_id) is not int or export_id <= 0:
59
+ raise ConfigurationError("Export ID must be a positive integer.")
60
+ self._export_id = export_id
61
+ return self
62
+
63
+ def withDestination(self, destination: str | Path) -> DatasetExportBuilder:
64
+ if not isinstance(destination, (str, Path)) or (isinstance(destination, str) and not destination.strip()):
65
+ raise ConfigurationError("Supply a destination directory.")
66
+ self._destination = destination
67
+ return self
68
+
69
+ def withReview(self, status: ReviewStatus | str | None) -> DatasetExportBuilder:
70
+ self._review = review_status(status)
71
+ return self
72
+
73
+ def withQA(self, status: QAStatus | str | None) -> DatasetExportBuilder:
74
+ self._qa = qa_status(status)
75
+ return self
76
+
77
+ def export(self, *, wait_timeout: float = 7200, poll_interval: float = 5, overwrite: bool = False) -> Path:
78
+ return self._execute(self._review, self._qa, wait_timeout, poll_interval, overwrite)
79
+
80
+ def exportCurated(self, *, wait_timeout: float = 7200, poll_interval: float = 5, overwrite: bool = False) -> Path:
81
+ """Use APPROVED for both filters without changing the saved builder."""
82
+ return self._execute(ReviewStatus.APPROVED, QAStatus.APPROVED, wait_timeout, poll_interval, overwrite)
83
+
84
+ def _execute(self, review, qa, wait_timeout, poll_interval, overwrite) -> Path:
85
+ fields = (
86
+ (self._workspace, "withWorkspace"), (self._project, "withProject"),
87
+ (self._dataset, "withDataset"), (self._format, "withFormat"),
88
+ (self._version, "withVersion"), (self._destination, "withDestination"),
89
+ )
90
+ missing = [name for value, name in fields if value is None]
91
+ if missing:
92
+ raise ConfigurationError("Complete the export configuration: " + ", ".join(missing) + ".")
93
+ dataset = self._client.workspace(self._workspace).project(self._project).dataset(
94
+ self._dataset, dataset_version=self._dataset_version,
95
+ )
96
+ return dataset.export(
97
+ self._format, self._version, self._destination, review=review, qa=qa,
98
+ wait_timeout=wait_timeout, poll_interval=poll_interval, overwrite=overwrite,
99
+ export_id=self._export_id,
100
+ )
101
+
102
+ def __repr__(self) -> str:
103
+ return "DatasetExportBuilder()"
vqflow/client.py ADDED
@@ -0,0 +1,64 @@
1
+ """The public VQFlow client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .exceptions import ConfigurationError
9
+ from .models import required_name
10
+ from .transport import Transport
11
+
12
+ if TYPE_CHECKING:
13
+ from .resources import Workspace
14
+
15
+
16
+ class VQFlow:
17
+ """Connect to VQFlow using a caller-supplied API key.
18
+
19
+ Credentials are read only from the argument or VQFLOW_API_KEY. Environment
20
+ files are never discovered or loaded. The API base URL includes /api/v1.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str | None = None,
26
+ *,
27
+ base_url: str | None = None,
28
+ timeout: float = 30,
29
+ max_retries: int = 2,
30
+ ) -> None:
31
+ key = api_key if api_key is not None else os.environ.get("VQFLOW_API_KEY")
32
+ url = base_url if base_url is not None else os.environ.get("VQFLOW_API_URL")
33
+ if not key:
34
+ raise ConfigurationError("Supply api_key or set VQFLOW_API_KEY.")
35
+ if not url:
36
+ raise ConfigurationError("Supply base_url or set VQFLOW_API_URL to the HTTPS API base URL.")
37
+ self._transport = Transport(url, key, timeout=timeout, max_retries=max_retries)
38
+ self._closed = False
39
+
40
+ def workspace(self, name: str) -> Workspace:
41
+ """Select a workspace by its exact name; lookup occurs on export."""
42
+ from .resources import Workspace
43
+
44
+ self._ensure_open()
45
+ return Workspace(self, required_name(name, "Workspace name"))
46
+
47
+ def close(self) -> None:
48
+ if not self._closed:
49
+ self._transport.close()
50
+ self._closed = True
51
+
52
+ def _ensure_open(self) -> None:
53
+ if self._closed:
54
+ raise ConfigurationError("This VQFlow client is closed. Create a new client to continue.")
55
+
56
+ def __enter__(self) -> VQFlow:
57
+ self._ensure_open()
58
+ return self
59
+
60
+ def __exit__(self, *_: object) -> None:
61
+ self.close()
62
+
63
+ def __repr__(self) -> str:
64
+ return f"VQFlow(closed={self._closed})"
vqflow/exceptions.py ADDED
@@ -0,0 +1,70 @@
1
+ """Public errors with intentionally limited, safe diagnostic information."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class VQFlowError(Exception):
7
+ """Base class for errors reported by the VQFlow client."""
8
+
9
+
10
+ class ConfigurationError(VQFlowError):
11
+ """The supplied client configuration is invalid."""
12
+
13
+
14
+ class AuthenticationError(VQFlowError):
15
+ """The server did not accept the supplied API key."""
16
+
17
+
18
+ class PermissionDeniedError(VQFlowError):
19
+ """The API key cannot perform the requested operation."""
20
+
21
+
22
+ class NotFoundError(VQFlowError):
23
+ """The requested resource could not be found."""
24
+
25
+
26
+ class AmbiguousResourceError(VQFlowError):
27
+ """More than one resource matches the supplied selection."""
28
+
29
+
30
+ class ExportConflictError(VQFlowError):
31
+ """An existing export conflicts with the requested export."""
32
+
33
+
34
+ class TransportError(VQFlowError):
35
+ """An API request could not be completed."""
36
+
37
+
38
+ class ProtocolError(VQFlowError):
39
+ """The server response does not satisfy the expected contract."""
40
+
41
+
42
+ class DownloadError(VQFlowError):
43
+ """An archive could not be downloaded and safely saved."""
44
+
45
+
46
+ class _ExportStateError(VQFlowError):
47
+ def __init__(
48
+ self,
49
+ message: str,
50
+ *,
51
+ task_id: int | str | None = None,
52
+ export_id: int | str | None = None,
53
+ status: str | None = None,
54
+ ) -> None:
55
+ super().__init__(message)
56
+ self.task_id = task_id
57
+ self.export_id = export_id
58
+ self.status = status
59
+
60
+
61
+ class ExportFailedError(_ExportStateError):
62
+ """The export task finished unsuccessfully."""
63
+
64
+
65
+ class ExportTimeoutError(_ExportStateError):
66
+ """The export did not become available before the wait deadline."""
67
+
68
+
69
+ class ExportCreationUncertainError(_ExportStateError, TransportError):
70
+ """Creation may have succeeded; inspect existing exports before retrying."""
vqflow/exports.py ADDED
@@ -0,0 +1,322 @@
1
+ """Resolve, generate, and download a versioned dataset export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Iterator, Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .exceptions import (
12
+ AmbiguousResourceError,
13
+ ConfigurationError,
14
+ ExportConflictError,
15
+ ExportCreationUncertainError,
16
+ ExportFailedError,
17
+ ExportTimeoutError,
18
+ PermissionDeniedError,
19
+ ProtocolError,
20
+ TransportError,
21
+ )
22
+ from .models import ExportFormat, QAStatus, ReviewStatus
23
+
24
+
25
+ def _integer(value: Any, *, minimum: int = 0) -> bool:
26
+ return isinstance(value, int) and not isinstance(value, bool) and value >= minimum
27
+
28
+
29
+ def iter_pages(
30
+ transport: Any, path: str, params: Mapping[str, Any] | None = None
31
+ ) -> Iterator[dict[str, Any]]:
32
+ """Yield records from Core's one-based requests and zero-based page metadata."""
33
+ query = dict(params or {})
34
+ query.pop("page", None)
35
+ size = query.setdefault("size", 100)
36
+ if not _integer(size, minimum=1):
37
+ raise ConfigurationError("Page size must be a positive integer.")
38
+ first_totals: tuple[int, int] | None = None
39
+ received = 0
40
+ page = 1
41
+ while True:
42
+ response = transport.get(path, params={**query, "page": page})
43
+ if not isinstance(response, dict):
44
+ raise ProtocolError("The server returned an invalid page response.")
45
+ records = response.get("data")
46
+ links = response.get("links")
47
+ if not isinstance(records, list) or not isinstance(links, dict):
48
+ raise ProtocolError("The server returned invalid pagination metadata.")
49
+ if not all(_integer(links.get(key)) for key in (
50
+ "totalPages", "totalObjects", "currentPage", "pageSize"
51
+ )):
52
+ raise ProtocolError("The server returned invalid pagination metadata.")
53
+ total_pages, total_objects = links["totalPages"], links["totalObjects"]
54
+ if links["currentPage"] != page - 1 or links["pageSize"] != size:
55
+ raise ProtocolError("The server returned an unexpected page.")
56
+ expected_pages = (total_objects + size - 1) // size
57
+ if total_pages != expected_pages:
58
+ raise ProtocolError("The server returned inconsistent pagination totals.")
59
+ totals = (total_pages, total_objects)
60
+ if first_totals is not None and totals != first_totals:
61
+ raise ProtocolError("The result list changed while it was being read. Try again.")
62
+ first_totals = totals
63
+ expected_count = min(size, max(total_objects - received, 0))
64
+ if len(records) != expected_count or not all(isinstance(row, dict) for row in records):
65
+ raise ProtocolError("The server returned an incomplete or invalid page.")
66
+ for row in records:
67
+ yield row
68
+ received += len(records)
69
+ if page >= total_pages:
70
+ return
71
+ page += 1
72
+
73
+
74
+ def _option(value: Any, enum_type: Any, name: str) -> str:
75
+ try:
76
+ return enum_type(value).value
77
+ except (TypeError, ValueError):
78
+ raise ConfigurationError(f"Choose a supported {name}.") from None
79
+
80
+
81
+ def _verify_scope(record: dict[str, Any], dataset: dict[str, Any], version: str) -> None:
82
+ if not _integer(record.get("id"), minimum=1):
83
+ raise ProtocolError("The server returned an invalid export identifier.")
84
+ if not _integer(record.get("datasetId"), minimum=1) or record["datasetId"] != dataset["id"]:
85
+ raise ProtocolError("The server returned an export for a different dataset.")
86
+ if "datasetVersion" not in record or record["datasetVersion"] != dataset.get("version"):
87
+ raise ProtocolError("The server returned an export for a different dataset version.")
88
+ if record.get("exportVersion") != version:
89
+ raise ProtocolError("The server returned a different export version.")
90
+ for field, expected in (
91
+ ("datasetName", dataset.get("name")),
92
+ ("workspaceName", dataset.get("workspaceName")),
93
+ ("projectName", dataset.get("projectName")),
94
+ ):
95
+ if expected is not None and record.get(field) != expected:
96
+ raise ProtocolError("The server returned an export with a different dataset scope.")
97
+
98
+
99
+ def _find_export(
100
+ transport: Any, dataset: dict[str, Any], version: str, format_value: str
101
+ ) -> dict[str, Any] | None:
102
+ records = list(iter_pages(transport, "dataset-exports", {
103
+ "datasetId": dataset["id"], "exportVersion": version,
104
+ "sortKey": "id", "sortOrder": "ASCENDING",
105
+ }))
106
+ seen: set[int] = set()
107
+ for record in records:
108
+ _verify_scope(record, dataset, version)
109
+ if record["id"] in seen:
110
+ raise ProtocolError("The server repeated an export record across pages.")
111
+ seen.add(record["id"])
112
+ matches = [record for record in records if record.get("exportFormat") == format_value
113
+ and record.get("resultForm") == "ZIP_ARCHIVE"]
114
+ if len(matches) > 1:
115
+ raise AmbiguousResourceError("Several exports match this version and format. Choose a unique export version.")
116
+ if matches:
117
+ return matches[0]
118
+ if records:
119
+ raise ExportConflictError("This export version exists with a different format or delivery type. Choose another version.")
120
+ return None
121
+
122
+
123
+ def _verify_task(
124
+ task: Any, dataset: dict[str, Any], version: str, format_value: str,
125
+ review_value: str | None, qa_value: str | None, *, task_id: int, export_id: int,
126
+ verify_selection: bool = True,
127
+ ) -> dict[str, Any]:
128
+ if not isinstance(task, dict):
129
+ raise ProtocolError("The server returned an invalid export task.")
130
+ _verify_scope(task, dataset, version)
131
+ if task["id"] != task_id or not _integer(task.get("datasetExportId"), minimum=1) or task["datasetExportId"] != export_id:
132
+ raise ProtocolError("The server returned an unrelated export task.")
133
+ if task.get("exportFormat") != format_value or task.get("resultForm") != "ZIP_ARCHIVE":
134
+ raise ExportConflictError("The export task uses a different format or delivery type.")
135
+ if verify_selection:
136
+ _verify_selection(task, review_value, qa_value)
137
+ if task.get("canAccessArtifact") is False:
138
+ raise PermissionDeniedError("You do not have permission to download this export.")
139
+ if task.get("status") not in {"SCHEDULED", "IN_PROGRESS", "COMPLETED", "FAILED", "CANCELLED"}:
140
+ raise ProtocolError("The server returned an unknown export task status.")
141
+ return task
142
+
143
+
144
+ def _verify_selection(task: dict[str, Any], review_value: str | None, qa_value: str | None) -> None:
145
+ fields = (
146
+ "reviewStatus", "checkStatus", "categoryIds", "search", "mediaType",
147
+ "reviewerAssignmentState", "qaAssignmentState", "selectionMode", "selectedMediaCount",
148
+ )
149
+ if any(field not in task for field in fields):
150
+ raise ExportConflictError("The existing export's selection cannot be verified. Choose another export version.")
151
+ if (
152
+ task["reviewStatus"] != review_value or task["checkStatus"] != qa_value
153
+ or task["categoryIds"] not in (None, [])
154
+ or task["search"] not in (None, "")
155
+ or task["mediaType"] is not None
156
+ or task["reviewerAssignmentState"] is not None
157
+ or task["qaAssignmentState"] is not None
158
+ or task["selectionMode"] != "FILTERED_RESULTS"
159
+ # The backend replaces this count with the prepared total for every mode.
160
+ or not _integer(task["selectedMediaCount"])
161
+ ):
162
+ raise ExportConflictError("This export version uses a different media selection. Choose another version.")
163
+
164
+
165
+ def _verify_export(
166
+ record: Any, dataset: dict[str, Any], version: str, format_value: str,
167
+ *, export_id: int, task_id: int | None,
168
+ ) -> dict[str, Any]:
169
+ if not isinstance(record, dict):
170
+ raise ProtocolError("The server returned an invalid export record.")
171
+ _verify_scope(record, dataset, version)
172
+ if record["id"] != export_id or record.get("taskId") != task_id:
173
+ raise ProtocolError("The server returned an unrelated export record.")
174
+ if task_id is not None and not _integer(record.get("taskId"), minimum=1):
175
+ raise ProtocolError("The server returned an invalid export task reference.")
176
+ if record.get("exportFormat") != format_value or record.get("resultForm") != "ZIP_ARCHIVE":
177
+ raise ExportConflictError("The export uses a different format or delivery type.")
178
+ if record.get("canAccessArtifact") is False:
179
+ raise PermissionDeniedError("You do not have permission to download this export.")
180
+ if record.get("status") not in {"SCHEDULED", "IN_PROGRESS", "READY", "FAILED", "CANCELLED"}:
181
+ raise ProtocolError("The server returned an unknown export status.")
182
+ return record
183
+
184
+
185
+ def _download_zip(transport: Any, export_id: int, destination: Path, overwrite: bool) -> Path:
186
+ access = transport.get(f"dataset-exports/{export_id}/access")
187
+ if not isinstance(access, dict) or not _integer(access.get("id"), minimum=1) or access["id"] != export_id:
188
+ raise ProtocolError("The server returned download access for a different export.")
189
+ if access.get("resultForm") != "ZIP_ARCHIVE" or not isinstance(access.get("url"), str) or not access["url"].strip():
190
+ raise ProtocolError("The server returned invalid ZIP download access.")
191
+ return transport.download(access["url"], destination,
192
+ file_name=f"vqflow-export-{export_id}.zip", overwrite=overwrite)
193
+
194
+
195
+ def export_dataset(
196
+ transport: Any,
197
+ dataset: dict[str, Any],
198
+ *,
199
+ format: ExportFormat,
200
+ version: str,
201
+ destination: str | Path,
202
+ review: ReviewStatus | None = None,
203
+ qa: QAStatus | None = None,
204
+ export_id: int | None = None,
205
+ wait_timeout: float = 7200,
206
+ poll_interval: float = 5,
207
+ overwrite: bool = False,
208
+ ) -> Path:
209
+ """Download an exact version, generating its ZIP only when that version is absent.
210
+
211
+ A local wait timeout leaves the server task running. The raised exception keeps
212
+ its task and export identifiers so the same version can be requested again.
213
+ An explicit export_id downloads that exact artifact with its recorded selection;
214
+ it never creates an export and cannot be combined with media status filters.
215
+ """
216
+ if not isinstance(dataset, dict) or not _integer(dataset.get("id"), minimum=1):
217
+ raise ConfigurationError("Choose a dataset with a valid identifier.")
218
+ dataset = dict(dataset)
219
+ if not isinstance(version, str) or not version.strip() or len(version.strip()) > 255:
220
+ raise ConfigurationError("Export version must contain between 1 and 255 characters.")
221
+ version = version.strip()
222
+ format_value = _option(format, ExportFormat, "export format")
223
+ review_value = None if review is None else _option(review, ReviewStatus, "review status")
224
+ qa_value = None if qa is None else _option(qa, QAStatus, "QA status")
225
+ explicit_id = export_id is not None
226
+ if explicit_id and not _integer(export_id, minimum=1):
227
+ raise ConfigurationError("Export ID must be a positive integer.")
228
+ if explicit_id and (review is not None or qa is not None):
229
+ raise ConfigurationError("An exact export ID cannot be combined with review or QA filters.")
230
+ for value, minimum, label in ((wait_timeout, 0, "Wait timeout"), (poll_interval, 0, "Poll interval")):
231
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < minimum or (label == "Poll interval" and value == 0):
232
+ raise ConfigurationError(f"{label} must be a finite {'positive' if label == 'Poll interval' else 'nonnegative'} number.")
233
+ if not isinstance(overwrite, bool):
234
+ raise ConfigurationError("Overwrite must be true or false.")
235
+ if not isinstance(destination, (str, Path)) or (isinstance(destination, str) and not destination.strip()):
236
+ raise ConfigurationError("Choose a destination directory.")
237
+ if "\x00" in str(destination):
238
+ raise ConfigurationError("Choose a valid destination directory.")
239
+ destination_path = Path(destination).expanduser()
240
+ if destination_path.exists() and not destination_path.is_dir():
241
+ raise ConfigurationError("The destination must be a directory.")
242
+ deadline = time.monotonic() + wait_timeout
243
+ if explicit_id:
244
+ record = transport.get(f"dataset-exports/{export_id}")
245
+ if not isinstance(record, dict):
246
+ raise ProtocolError("The server returned an invalid export record.")
247
+ task_id = record.get("taskId")
248
+ _verify_export(record, dataset, version, format_value, export_id=export_id, task_id=task_id)
249
+ else:
250
+ record = _find_export(transport, dataset, version, format_value)
251
+ created = record is None
252
+ if record is None:
253
+ payload: dict[str, Any] = {
254
+ "datasetId": dataset["id"], "exportVersion": version,
255
+ "exportFormat": format_value, "resultForm": "ZIP_ARCHIVE",
256
+ }
257
+ if review_value is not None:
258
+ payload["reviewStatus"] = review_value
259
+ if qa_value is not None:
260
+ payload["checkStatus"] = qa_value
261
+ task = transport.post("datasets/export", payload)
262
+ if not isinstance(task, dict) or not _integer(task.get("id"), minimum=1) or not _integer(task.get("datasetExportId"), minimum=1):
263
+ raise ExportCreationUncertainError("The server accepted the export request but its task could not be confirmed. Check existing exports before trying again.")
264
+ task_id, export_id = task["id"], task["datasetExportId"]
265
+ else:
266
+ export_id = record["id"]
267
+ task_id = record.get("taskId")
268
+ if explicit_id and record["status"] in {"FAILED", "CANCELLED"}:
269
+ raise ExportFailedError("The export is not available for download.", task_id=task_id,
270
+ export_id=export_id, status=record["status"])
271
+ if explicit_id and task_id is None:
272
+ if record["status"] == "READY":
273
+ return _download_zip(transport, export_id, destination_path, overwrite)
274
+ raise ExportConflictError("This export is not ready and has no task to monitor.")
275
+ if not _integer(task_id, minimum=1):
276
+ raise ExportConflictError("The existing export's selection cannot be verified. Choose another export version.")
277
+ _verify_export(record, dataset, version, format_value, export_id=export_id, task_id=task_id)
278
+ task = transport.get(f"dataset-export-tasks/{task_id}")
279
+ task = _verify_task(task, dataset, version, format_value, review_value, qa_value,
280
+ task_id=task_id, export_id=export_id, verify_selection=not explicit_id)
281
+ if created:
282
+ try:
283
+ confirmed = _find_export(transport, dataset, version, format_value)
284
+ except TransportError:
285
+ raise ExportCreationUncertainError(
286
+ "The export task was created but its version could not be confirmed. Check the same version before trying again.",
287
+ task_id=task_id, export_id=export_id, status=task["status"],
288
+ ) from None
289
+ if confirmed is None:
290
+ raise ExportCreationUncertainError(
291
+ "The export task was created but its version could not be confirmed. Check the same version before trying again.",
292
+ task_id=task_id, export_id=export_id, status=task["status"],
293
+ )
294
+ _verify_export(confirmed, dataset, version, format_value, export_id=export_id, task_id=task_id)
295
+ while task["status"] != "COMPLETED":
296
+ status = task["status"]
297
+ if status in {"FAILED", "CANCELLED"}:
298
+ raise ExportFailedError("The export did not complete. Review its task before trying again.", task_id=task_id, export_id=export_id, status=status)
299
+ remaining = deadline - time.monotonic()
300
+ if remaining <= 0:
301
+ raise ExportTimeoutError("Waiting for the export timed out. Request the same version to check it again.", task_id=task_id, export_id=export_id, status=status)
302
+ time.sleep(min(poll_interval, remaining))
303
+ if time.monotonic() >= deadline:
304
+ raise ExportTimeoutError("Waiting for the export timed out. Request the same version to check it again.", task_id=task_id, export_id=export_id, status=status)
305
+ task = _verify_task(transport.get(f"dataset-export-tasks/{task_id}"), dataset, version,
306
+ format_value, review_value, qa_value, task_id=task_id,
307
+ export_id=export_id, verify_selection=not explicit_id)
308
+ record = _verify_export(transport.get(f"dataset-exports/{export_id}"), dataset, version,
309
+ format_value, export_id=export_id, task_id=task_id)
310
+ if record["status"] in {"FAILED", "CANCELLED"}:
311
+ raise ExportFailedError("The export is no longer available for download.", task_id=task_id, export_id=export_id, status=record["status"])
312
+ if record["status"] != "READY":
313
+ raise ProtocolError("The completed export is not ready for download. Try again.")
314
+ if not explicit_id:
315
+ # A second resolution detects duplicate versions created by concurrent callers.
316
+ confirmed = _find_export(transport, dataset, version, format_value)
317
+ if confirmed is None or confirmed["id"] != export_id:
318
+ raise ExportConflictError("The export version changed before download. Check existing exports before trying again.")
319
+ _verify_export(confirmed, dataset, version, format_value, export_id=export_id, task_id=task_id)
320
+ if confirmed["status"] != "READY":
321
+ raise ExportConflictError("The export changed before download. Check its task before trying again.")
322
+ return _download_zip(transport, export_id, destination_path, overwrite)
vqflow/models.py ADDED
@@ -0,0 +1,59 @@
1
+ """Supported dataset formats and media selection statuses."""
2
+
3
+ from enum import Enum
4
+
5
+ from .exceptions import ConfigurationError
6
+
7
+
8
+ class ExportFormat(str, Enum):
9
+ COCO = "COCO"
10
+ CATEGORY_FOLDERS = "CATEGORY_FOLDERS"
11
+ VQFLOW_NATIVE = "VQFLOW_NATIVE"
12
+
13
+
14
+ class ReviewStatus(str, Enum):
15
+ APPROVED = "APPROVED"
16
+ REJECTED = "REJECTED"
17
+ PENDING = "PENDING"
18
+ NONE = "NONE"
19
+
20
+
21
+ class QAStatus(str, Enum):
22
+ APPROVED = "APPROVED"
23
+ REJECTED = "REJECTED"
24
+ PENDING = "PENDING"
25
+ NONE = "NONE"
26
+
27
+
28
+ def export_format(value: ExportFormat | str) -> ExportFormat:
29
+ if isinstance(value, ExportFormat):
30
+ return value
31
+ if isinstance(value, str):
32
+ normalized = value.strip().upper().replace("-", "_").replace(" ", "_")
33
+ if normalized == "NATIVE":
34
+ normalized = "VQFLOW_NATIVE"
35
+ if normalized in ExportFormat.__members__:
36
+ return ExportFormat[normalized]
37
+ raise ConfigurationError("Choose coco, category_folders, or vqflow_native as the export format.")
38
+
39
+
40
+ def review_status(value: ReviewStatus | str | None) -> ReviewStatus | None:
41
+ if value is None or isinstance(value, ReviewStatus):
42
+ return value
43
+ if isinstance(value, str) and value.strip().upper() in ReviewStatus.__members__:
44
+ return ReviewStatus[value.strip().upper()]
45
+ raise ConfigurationError("Review status must be APPROVED, REJECTED, PENDING, NONE, or None.")
46
+
47
+
48
+ def qa_status(value: QAStatus | str | None) -> QAStatus | None:
49
+ if value is None or isinstance(value, QAStatus):
50
+ return value
51
+ if isinstance(value, str) and value.strip().upper() in QAStatus.__members__:
52
+ return QAStatus[value.strip().upper()]
53
+ raise ConfigurationError("QA status must be APPROVED, REJECTED, PENDING, NONE, or None.")
54
+
55
+
56
+ def required_name(value: object, field: str) -> str:
57
+ if not isinstance(value, str) or not value.strip():
58
+ raise ConfigurationError(f"{field} must be a non-empty string.")
59
+ return value.strip()
vqflow/py.typed ADDED
File without changes