diffio 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.
diffio/client.py ADDED
@@ -0,0 +1,1460 @@
1
+ import json
2
+ import mimetypes
3
+ import os
4
+ import tempfile
5
+ import time
6
+ import warnings
7
+ from urllib.parse import urlparse
8
+
9
+ import httpx
10
+
11
+ from .errors import DiffioApiError
12
+ from .types import (
13
+ AudioIsolationResult,
14
+ CreateGenerationResponse,
15
+ CreateProjectResponse,
16
+ DownloadType,
17
+ GenerationDownloadResponse,
18
+ GenerationProgressResponse,
19
+ ListProjectGenerationsResponse,
20
+ ListProjectsResponse,
21
+ ModelKey,
22
+ WebhookEventType,
23
+ WebhookMode,
24
+ WebhookPortalResponse,
25
+ WebhookTestEventResponse,
26
+ )
27
+
28
+ DEFAULT_BASE_URL = "https://us-central1-diffioai.cloudfunctions.net"
29
+ API_PREFIX = "v1"
30
+ MODEL_ENDPOINTS = {
31
+ "diffio-2": "diffio-2.0-generation",
32
+ "diffio-2-flash": "diffio-2.0-flash-generation",
33
+ "diffio-3": "diffio-3.0-generation",
34
+ }
35
+ DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]
36
+ DEFAULT_RETRY_BACKOFF = 0.5
37
+
38
+
39
+ class RequestOptions:
40
+ def __init__(
41
+ self,
42
+ *,
43
+ headers=None,
44
+ timeout=None,
45
+ timeoutInSeconds=None,
46
+ maxRetries=None,
47
+ retryBackoff=None,
48
+ retryStatusCodes=None,
49
+ apiKey=None,
50
+ ):
51
+ self.headers = headers or {}
52
+ self.timeout = timeoutInSeconds if timeoutInSeconds is not None else timeout
53
+ self.maxRetries = maxRetries
54
+ self.retryBackoff = retryBackoff
55
+ self.retryStatusCodes = retryStatusCodes
56
+ self.apiKey = apiKey
57
+
58
+
59
+ def _normalize_request_options(options):
60
+ if options is None:
61
+ return RequestOptions()
62
+ if isinstance(options, RequestOptions):
63
+ return options
64
+ if isinstance(options, dict):
65
+ timeout = options.get("timeout")
66
+ timeout_in_seconds = options.get("timeoutInSeconds")
67
+ if timeout_in_seconds is not None:
68
+ timeout = timeout_in_seconds
69
+ return RequestOptions(
70
+ headers=options.get("headers"),
71
+ timeout=timeout,
72
+ maxRetries=options.get("maxRetries"),
73
+ retryBackoff=options.get("retryBackoff"),
74
+ retryStatusCodes=options.get("retryStatusCodes"),
75
+ apiKey=options.get("apiKey"),
76
+ )
77
+ raise ValueError("requestOptions must be a RequestOptions or dict")
78
+
79
+
80
+ def _merge_request_options(base_options, override_options):
81
+ if override_options is None:
82
+ return base_options
83
+ override = _normalize_request_options(override_options)
84
+ headers = {}
85
+ if base_options.headers:
86
+ headers.update(base_options.headers)
87
+ if override.headers:
88
+ headers.update(override.headers)
89
+ return RequestOptions(
90
+ headers=headers,
91
+ timeout=override.timeout if override.timeout is not None else base_options.timeout,
92
+ maxRetries=override.maxRetries if override.maxRetries is not None else base_options.maxRetries,
93
+ retryBackoff=override.retryBackoff if override.retryBackoff is not None else base_options.retryBackoff,
94
+ retryStatusCodes=(
95
+ override.retryStatusCodes
96
+ if override.retryStatusCodes is not None
97
+ else base_options.retryStatusCodes
98
+ ),
99
+ apiKey=override.apiKey if override.apiKey is not None else base_options.apiKey,
100
+ )
101
+
102
+
103
+ def _default_request_options():
104
+ return RequestOptions(
105
+ headers={},
106
+ timeout=None,
107
+ maxRetries=0,
108
+ retryBackoff=DEFAULT_RETRY_BACKOFF,
109
+ retryStatusCodes=list(DEFAULT_RETRY_STATUS_CODES),
110
+ apiKey=None,
111
+ )
112
+
113
+
114
+ class DiffioClient:
115
+ def __init__(
116
+ self,
117
+ *,
118
+ apiKey=None,
119
+ baseUrl=None,
120
+ timeout=60.0,
121
+ timeoutInSeconds=None,
122
+ httpClient=None,
123
+ requestOptions=None,
124
+ ):
125
+ resolved_key = apiKey or os.environ.get("DIFFIO_API_KEY")
126
+ if not resolved_key:
127
+ raise ValueError("apiKey is required")
128
+
129
+ resolved_base = baseUrl or os.environ.get("DIFFIO_API_BASE_URL") or DEFAULT_BASE_URL
130
+ resolved_base = resolved_base.rstrip("/")
131
+ api_prefix = "" if resolved_base.endswith(f"/{API_PREFIX}") else API_PREFIX
132
+
133
+ self.apiKey = resolved_key
134
+ self.baseUrl = resolved_base
135
+ self._api_prefix = api_prefix
136
+ resolved_timeout = timeoutInSeconds if timeoutInSeconds is not None else timeout
137
+ if httpClient is None:
138
+ self._client = httpx.Client(base_url=resolved_base, timeout=resolved_timeout)
139
+ self._owns_client = True
140
+ else:
141
+ self._client = httpClient
142
+ self._owns_client = False
143
+ self._default_request_options = _merge_request_options(_default_request_options(), requestOptions)
144
+
145
+ self.audio_isolation = AudioIsolationClient(self)
146
+ self.generations = GenerationsClient(self)
147
+ self.projects = ProjectsClient(self)
148
+ self.webhooks = WebhooksClient(self)
149
+
150
+ def __enter__(self):
151
+ return self
152
+
153
+ def __exit__(self, exc_type, exc, tb):
154
+ self.close()
155
+
156
+ def close(self):
157
+ if self._owns_client:
158
+ self._client.close()
159
+
160
+ def create_project(
161
+ self,
162
+ *,
163
+ filePath,
164
+ contentType=None,
165
+ contentLength=None,
166
+ params=None,
167
+ fileFormat=None,
168
+ requestOptions=None,
169
+ ):
170
+ payload = _build_create_project_payload(
171
+ filePath=filePath,
172
+ contentType=contentType,
173
+ contentLength=contentLength,
174
+ params=params,
175
+ fileFormat=fileFormat,
176
+ )
177
+
178
+ response = self._request("POST", "create_project", json_payload=payload, requestOptions=requestOptions)
179
+ project = CreateProjectResponse.from_dict(response)
180
+ self._upload_file(
181
+ uploadUrl=project.uploadUrl,
182
+ uploadMethod=project.uploadMethod,
183
+ filePath=filePath,
184
+ contentType=payload["contentType"],
185
+ requestOptions=requestOptions,
186
+ )
187
+ return project
188
+
189
+ def _upload_file(
190
+ self,
191
+ *,
192
+ uploadUrl,
193
+ uploadMethod=None,
194
+ filePath=None,
195
+ data=None,
196
+ contentType=None,
197
+ requestOptions=None,
198
+ ):
199
+ if (filePath is None) == (data is None):
200
+ raise ValueError("Provide filePath or data")
201
+
202
+ resolved_content_type = contentType
203
+ if resolved_content_type is None and filePath is not None:
204
+ resolved_content_type = _guess_content_type(filePath)
205
+ if resolved_content_type is None:
206
+ resolved_content_type = "application/octet-stream"
207
+
208
+ method = (uploadMethod or "PUT").upper()
209
+ merged_options = _merge_request_options(self._default_request_options, requestOptions)
210
+ headers = {"Content-Type": resolved_content_type}
211
+ if _is_storage_emulator_url(uploadUrl):
212
+ headers["Authorization"] = "Bearer owner"
213
+ headers = _merge_headers(headers, merged_options.headers)
214
+ timeout = merged_options.timeout
215
+ max_retries = merged_options.maxRetries if merged_options.maxRetries is not None else 0
216
+ retry_backoff = (
217
+ merged_options.retryBackoff if merged_options.retryBackoff is not None else DEFAULT_RETRY_BACKOFF
218
+ )
219
+ retry_statuses = (
220
+ merged_options.retryStatusCodes
221
+ if merged_options.retryStatusCodes is not None
222
+ else DEFAULT_RETRY_STATUS_CODES
223
+ )
224
+
225
+ attempt = 0
226
+ while True:
227
+ try:
228
+ if filePath is not None:
229
+ with open(filePath, "rb") as handle:
230
+ request_kwargs = {
231
+ "method": method,
232
+ "url": uploadUrl,
233
+ "headers": headers,
234
+ "content": handle,
235
+ }
236
+ if timeout is not None:
237
+ request_kwargs["timeout"] = timeout
238
+ response = self._client.request(**request_kwargs)
239
+ else:
240
+ request_kwargs = {
241
+ "method": method,
242
+ "url": uploadUrl,
243
+ "headers": headers,
244
+ "content": data,
245
+ }
246
+ if timeout is not None:
247
+ request_kwargs["timeout"] = timeout
248
+ response = self._client.request(**request_kwargs)
249
+ except httpx.RequestError:
250
+ if attempt >= max_retries:
251
+ raise
252
+ _sleep_retry(attempt, retry_backoff)
253
+ attempt += 1
254
+ continue
255
+
256
+ if retry_statuses and response.status_code in retry_statuses and attempt < max_retries:
257
+ response.close()
258
+ _sleep_retry(attempt, retry_backoff)
259
+ attempt += 1
260
+ continue
261
+
262
+ _raise_for_error(response)
263
+ return
264
+
265
+ def create_generation(
266
+ self,
267
+ *,
268
+ apiProjectId,
269
+ model="diffio-2",
270
+ sampling=None,
271
+ params=None,
272
+ requestOptions=None,
273
+ ):
274
+ endpoint = MODEL_ENDPOINTS.get(model)
275
+ if not endpoint:
276
+ raise ValueError(f"Unsupported model: {model}")
277
+
278
+ payload = {"apiProjectId": apiProjectId}
279
+ if sampling is not None:
280
+ payload["sampling"] = sampling
281
+ if params:
282
+ payload["params"] = params
283
+
284
+ response = self._request("POST", endpoint, json_payload=payload, requestOptions=requestOptions)
285
+ return CreateGenerationResponse.from_dict(response)
286
+
287
+ def list_projects(self, *, requestOptions=None):
288
+ """
289
+ Lists projects owned by the API key.
290
+
291
+ Returns
292
+ -------
293
+ ListProjectsResponse
294
+ Project summaries ordered by creation time.
295
+
296
+ Examples
297
+ --------
298
+ from diffio import DiffioClient
299
+
300
+ client = DiffioClient(apiKey="diffio_live_...")
301
+ projects = client.list_projects()
302
+ print(projects.projects[0].apiProjectId)
303
+ """
304
+ response = self._request("POST", "list_projects", json_payload={}, requestOptions=requestOptions)
305
+ return ListProjectsResponse.from_dict(response)
306
+
307
+ def list_project_generations(
308
+ self,
309
+ *,
310
+ apiProjectId,
311
+ requestOptions=None,
312
+ ):
313
+ """
314
+ Lists generations for a project owned by the API key.
315
+
316
+ Parameters
317
+ ----------
318
+ apiProjectId : str
319
+ The project id to list generations for.
320
+
321
+ Returns
322
+ -------
323
+ ListProjectGenerationsResponse
324
+ Generation summaries ordered by creation time.
325
+
326
+ Examples
327
+ --------
328
+ from diffio import DiffioClient
329
+
330
+ client = DiffioClient(apiKey="diffio_live_...")
331
+ generations = client.list_project_generations(apiProjectId="proj_123")
332
+ print(generations.generations[0].generationId)
333
+ """
334
+ if not apiProjectId:
335
+ raise ValueError("apiProjectId is required")
336
+ response = self._request(
337
+ "POST",
338
+ "list_project_generations",
339
+ json_payload={"apiProjectId": apiProjectId},
340
+ requestOptions=requestOptions,
341
+ )
342
+ return ListProjectGenerationsResponse.from_dict(response)
343
+
344
+ def get_generation_progress(
345
+ self,
346
+ *,
347
+ generationId,
348
+ apiProjectId=None,
349
+ requestOptions=None,
350
+ ):
351
+ """
352
+ Gets progress for a generation.
353
+
354
+ Parameters
355
+ ----------
356
+ generationId : str
357
+ The generation id to query.
358
+ apiProjectId : str, optional
359
+ Optional project id, used to speed up lookup.
360
+
361
+ Returns
362
+ -------
363
+ GenerationProgressResponse
364
+ Current generation progress.
365
+
366
+ Examples
367
+ --------
368
+ from diffio import DiffioClient
369
+
370
+ client = DiffioClient(apiKey="diffio_live_...")
371
+ progress = client.get_generation_progress(
372
+ generationId="gen_123",
373
+ apiProjectId="proj_123",
374
+ )
375
+ print(progress.status)
376
+ """
377
+ payload = {"generationId": generationId}
378
+ if apiProjectId is not None:
379
+ payload["apiProjectId"] = apiProjectId
380
+
381
+ response = self._request(
382
+ "POST",
383
+ "get_generation_progress",
384
+ json_payload=payload,
385
+ requestOptions=requestOptions,
386
+ )
387
+ return GenerationProgressResponse.from_dict(response)
388
+
389
+ def wait_for_generation(
390
+ self,
391
+ *,
392
+ generationId,
393
+ apiProjectId=None,
394
+ pollInterval=2.0,
395
+ timeout=600.0,
396
+ onProgress=None,
397
+ showProgress=False,
398
+ requestOptions=None,
399
+ ):
400
+ """
401
+ Polls generation progress until completion or failure.
402
+
403
+ Parameters
404
+ ----------
405
+ generationId : str
406
+ The generation id to wait for.
407
+ apiProjectId : str, optional
408
+ Optional project id, used to speed up lookup.
409
+ pollInterval : float
410
+ Seconds to wait between polls.
411
+ timeout : float
412
+ Maximum seconds to wait before timing out.
413
+ onProgress : callable, optional
414
+ Callback invoked with each GenerationProgressResponse.
415
+ showProgress : bool
416
+ If true, prints progress updates to stdout.
417
+ """
418
+ deadline = time.monotonic() + timeout
419
+ last_progress = None
420
+
421
+ while time.monotonic() < deadline:
422
+ progress = self.get_generation_progress(
423
+ generationId=generationId,
424
+ apiProjectId=apiProjectId,
425
+ requestOptions=requestOptions,
426
+ )
427
+ last_progress = progress
428
+ _report_progress(progress, onProgress=onProgress, showProgress=showProgress)
429
+
430
+ if progress.status == "complete":
431
+ return progress
432
+
433
+ if progress.status == "failed":
434
+ raise RuntimeError(
435
+ "Generation failed"
436
+ f" (preProcessing={progress.preProcessing.status},"
437
+ f" inference={progress.inference.status},"
438
+ f" error={progress.error},"
439
+ f" details={progress.errorDetails})"
440
+ )
441
+
442
+ time.sleep(pollInterval)
443
+
444
+ raise RuntimeError(
445
+ "Timed out waiting for generation completion"
446
+ f" (lastStatus={getattr(last_progress, 'status', None)})"
447
+ )
448
+
449
+ def get_generation_download(
450
+ self,
451
+ *,
452
+ generationId,
453
+ apiProjectId,
454
+ downloadType=None,
455
+ requestOptions=None,
456
+ ):
457
+ """
458
+ Gets a signed download URL for a generation.
459
+
460
+ Parameters
461
+ ----------
462
+ generationId : str
463
+ The generation id to download.
464
+ apiProjectId : str
465
+ The project id that owns the generation.
466
+ downloadType : str, optional
467
+ Optional download type, audio, mp3, or video.
468
+
469
+ Returns
470
+ -------
471
+ GenerationDownloadResponse
472
+ Signed download URL and file metadata.
473
+
474
+ Examples
475
+ --------
476
+ from diffio import DiffioClient
477
+
478
+ client = DiffioClient(apiKey="diffio_live_...")
479
+ download = client.get_generation_download(
480
+ generationId="gen_123",
481
+ apiProjectId="proj_123",
482
+ downloadType="audio",
483
+ )
484
+ print(download.downloadUrl)
485
+ """
486
+ payload = {"generationId": generationId, "apiProjectId": apiProjectId}
487
+ if downloadType is not None:
488
+ resolved_download_type, _ = _normalize_download_type(downloadType)
489
+ payload["downloadType"] = resolved_download_type
490
+
491
+ response = self._request(
492
+ "POST",
493
+ "get_generation_download",
494
+ json_payload=payload,
495
+ requestOptions=requestOptions,
496
+ )
497
+ return GenerationDownloadResponse.from_dict(response)
498
+
499
+ def get_webhooks_portal_access(
500
+ self,
501
+ *,
502
+ mode,
503
+ apiKeyId=None,
504
+ requestOptions=None,
505
+ ):
506
+ """
507
+ Gets the Svix App Portal URL for configuring webhooks.
508
+
509
+ Parameters
510
+ ----------
511
+ mode : str
512
+ Webhook mode, test or live.
513
+ apiKeyId : str, optional
514
+ Optional API key id to validate access.
515
+
516
+ Returns
517
+ -------
518
+ WebhookPortalResponse
519
+ App portal URL and mode.
520
+ """
521
+ if mode not in WebhookMode:
522
+ raise ValueError("mode must be test or live")
523
+ payload = {"mode": mode}
524
+ if apiKeyId is not None:
525
+ payload["apiKeyId"] = apiKeyId
526
+
527
+ response = self._request(
528
+ "POST",
529
+ "webhooks/app_portal_access",
530
+ json_payload=payload,
531
+ requestOptions=requestOptions,
532
+ )
533
+ return WebhookPortalResponse.from_dict(response)
534
+
535
+ def send_webhook_test_event(
536
+ self,
537
+ *,
538
+ eventType,
539
+ mode,
540
+ apiKeyId=None,
541
+ samplePayload=None,
542
+ requestOptions=None,
543
+ ):
544
+ """
545
+ Sends a test webhook event.
546
+
547
+ Parameters
548
+ ----------
549
+ eventType : str
550
+ One of the supported generation webhook event types.
551
+ mode : str
552
+ Webhook mode, test or live.
553
+ apiKeyId : str, optional
554
+ Optional API key id to validate access.
555
+ samplePayload : dict, optional
556
+ Sample payload overrides. Must be a dict.
557
+
558
+ Returns
559
+ -------
560
+ WebhookTestEventResponse
561
+ Webhook message metadata.
562
+ """
563
+ if eventType not in WebhookEventType:
564
+ raise ValueError("eventType is not supported")
565
+ if mode not in WebhookMode:
566
+ raise ValueError("mode must be test or live")
567
+ if samplePayload is not None and not isinstance(samplePayload, dict):
568
+ raise ValueError("samplePayload must be an object")
569
+
570
+ payload = {"eventType": eventType, "mode": mode}
571
+ if apiKeyId is not None:
572
+ payload["apiKeyId"] = apiKeyId
573
+ if samplePayload is not None:
574
+ payload["samplePayload"] = samplePayload
575
+
576
+ response = self._request(
577
+ "POST",
578
+ "webhooks/send_test_event",
579
+ json_payload=payload,
580
+ requestOptions=requestOptions,
581
+ )
582
+ return WebhookTestEventResponse.from_dict(response)
583
+
584
+ def restore_audio(
585
+ self,
586
+ *,
587
+ filePath,
588
+ contentType=None,
589
+ contentLength=None,
590
+ fileFormat=None,
591
+ model="diffio-2",
592
+ sampling=None,
593
+ projectParams=None,
594
+ generationParams=None,
595
+ downloadType="audio",
596
+ pollInterval=2.0,
597
+ timeout=600.0,
598
+ onProgress=None,
599
+ showProgress=False,
600
+ requestOptions=None,
601
+ progressRequestOptions=None,
602
+ downloadRequestOptions=None,
603
+ raiseOnError=False,
604
+ ):
605
+ return self.audio_isolation.restore_audio(
606
+ filePath=filePath,
607
+ contentType=contentType,
608
+ contentLength=contentLength,
609
+ fileFormat=fileFormat,
610
+ model=model,
611
+ sampling=sampling,
612
+ projectParams=projectParams,
613
+ generationParams=generationParams,
614
+ downloadType=downloadType,
615
+ pollInterval=pollInterval,
616
+ timeout=timeout,
617
+ onProgress=onProgress,
618
+ showProgress=showProgress,
619
+ requestOptions=requestOptions,
620
+ progressRequestOptions=progressRequestOptions,
621
+ downloadRequestOptions=downloadRequestOptions,
622
+ raiseOnError=raiseOnError,
623
+ )
624
+
625
+ def _request(self, method, path, *, json_payload, requestOptions=None):
626
+ request_path = path.lstrip("/")
627
+ if self._api_prefix:
628
+ request_path = f"{self._api_prefix}/{request_path}"
629
+ merged_options = _merge_request_options(self._default_request_options, requestOptions)
630
+ api_key = merged_options.apiKey or self.apiKey
631
+ headers = _merge_headers({"Authorization": f"Bearer {api_key}"}, merged_options.headers)
632
+ timeout = merged_options.timeout
633
+ max_retries = merged_options.maxRetries if merged_options.maxRetries is not None else 0
634
+ retry_backoff = (
635
+ merged_options.retryBackoff if merged_options.retryBackoff is not None else DEFAULT_RETRY_BACKOFF
636
+ )
637
+ retry_statuses = (
638
+ merged_options.retryStatusCodes
639
+ if merged_options.retryStatusCodes is not None
640
+ else DEFAULT_RETRY_STATUS_CODES
641
+ )
642
+
643
+ attempt = 0
644
+ while True:
645
+ try:
646
+ request_kwargs = {
647
+ "method": method,
648
+ "url": request_path,
649
+ "headers": headers,
650
+ "json": json_payload,
651
+ }
652
+ if timeout is not None:
653
+ request_kwargs["timeout"] = timeout
654
+ response = self._client.request(**request_kwargs)
655
+ except httpx.RequestError:
656
+ if attempt >= max_retries:
657
+ raise
658
+ _sleep_retry(attempt, retry_backoff)
659
+ attempt += 1
660
+ continue
661
+
662
+ if retry_statuses and response.status_code in retry_statuses and attempt < max_retries:
663
+ response.close()
664
+ _sleep_retry(attempt, retry_backoff)
665
+ attempt += 1
666
+ continue
667
+
668
+ return _raise_for_error(response)
669
+
670
+
671
+ class GenerationsClient:
672
+ def __init__(self, parent):
673
+ self._parent = parent
674
+
675
+ def create(
676
+ self,
677
+ *,
678
+ apiProjectId,
679
+ model="diffio-2",
680
+ sampling=None,
681
+ params=None,
682
+ requestOptions=None,
683
+ ):
684
+ return self._parent.create_generation(
685
+ apiProjectId=apiProjectId,
686
+ model=model,
687
+ sampling=sampling,
688
+ params=params,
689
+ requestOptions=requestOptions,
690
+ )
691
+
692
+ def get_progress(
693
+ self,
694
+ *,
695
+ generationId,
696
+ apiProjectId=None,
697
+ requestOptions=None,
698
+ ):
699
+ return self._parent.get_generation_progress(
700
+ generationId=generationId,
701
+ apiProjectId=apiProjectId,
702
+ requestOptions=requestOptions,
703
+ )
704
+
705
+ def get_download(
706
+ self,
707
+ *,
708
+ generationId,
709
+ apiProjectId,
710
+ downloadType=None,
711
+ requestOptions=None,
712
+ ):
713
+ return self._parent.get_generation_download(
714
+ generationId=generationId,
715
+ apiProjectId=apiProjectId,
716
+ downloadType=downloadType,
717
+ requestOptions=requestOptions,
718
+ )
719
+
720
+ def download(
721
+ self,
722
+ *,
723
+ generationId,
724
+ apiProjectId,
725
+ downloadFilePath,
726
+ downloadType=None,
727
+ requestOptions=None,
728
+ ):
729
+ """
730
+ Downloads a generation result directly to a file path.
731
+
732
+ Parameters
733
+ ----------
734
+ generationId : str
735
+ The generation id to download.
736
+ apiProjectId : str
737
+ The project id that owns the generation.
738
+ downloadFilePath : str
739
+ Local file path to write the downloaded media to.
740
+ downloadType : str, optional
741
+ Optional download type, audio, mp3, or video.
742
+
743
+ Returns
744
+ -------
745
+ GenerationDownloadResponse
746
+ Download metadata for the saved file.
747
+ """
748
+ resolved_path = os.fspath(downloadFilePath)
749
+ if not resolved_path:
750
+ raise ValueError("downloadFilePath is required")
751
+ if downloadType is not None:
752
+ resolved_download_type, _ = _normalize_download_type(downloadType)
753
+ else:
754
+ resolved_download_type = None
755
+
756
+ download = self._parent.get_generation_download(
757
+ generationId=generationId,
758
+ apiProjectId=apiProjectId,
759
+ downloadType=resolved_download_type,
760
+ requestOptions=requestOptions,
761
+ )
762
+ _warn_download_extension_mismatch(download, resolved_path)
763
+ _download_to_file(self._parent, download.downloadUrl, resolved_path, requestOptions=requestOptions)
764
+ return download
765
+
766
+ def wait_for_complete(
767
+ self,
768
+ *,
769
+ generationId,
770
+ apiProjectId=None,
771
+ pollInterval=2.0,
772
+ timeout=600.0,
773
+ onProgress=None,
774
+ showProgress=False,
775
+ requestOptions=None,
776
+ ):
777
+ return self._parent.wait_for_generation(
778
+ generationId=generationId,
779
+ apiProjectId=apiProjectId,
780
+ pollInterval=pollInterval,
781
+ timeout=timeout,
782
+ onProgress=onProgress,
783
+ showProgress=showProgress,
784
+ requestOptions=requestOptions,
785
+ )
786
+
787
+ def create_and_wait(
788
+ self,
789
+ *,
790
+ apiProjectId,
791
+ model="diffio-2",
792
+ sampling=None,
793
+ params=None,
794
+ pollInterval=2.0,
795
+ timeout=600.0,
796
+ onProgress=None,
797
+ showProgress=False,
798
+ requestOptions=None,
799
+ progressRequestOptions=None,
800
+ ):
801
+ resolved_progress_options = requestOptions if progressRequestOptions is None else progressRequestOptions
802
+ generation = self.create(
803
+ apiProjectId=apiProjectId,
804
+ model=model,
805
+ sampling=sampling,
806
+ params=params,
807
+ requestOptions=requestOptions,
808
+ )
809
+ progress = self.wait_for_complete(
810
+ generationId=generation.generationId,
811
+ apiProjectId=generation.apiProjectId,
812
+ pollInterval=pollInterval,
813
+ timeout=timeout,
814
+ onProgress=onProgress,
815
+ showProgress=showProgress,
816
+ requestOptions=resolved_progress_options,
817
+ )
818
+ return generation, progress
819
+
820
+
821
+ class ProjectsClient:
822
+ def __init__(self, parent):
823
+ self._parent = parent
824
+
825
+ def list(self, *, requestOptions=None):
826
+ return self._parent.list_projects(requestOptions=requestOptions)
827
+
828
+ def list_generations(
829
+ self,
830
+ *,
831
+ apiProjectId,
832
+ requestOptions=None,
833
+ ):
834
+ return self._parent.list_project_generations(
835
+ apiProjectId=apiProjectId,
836
+ requestOptions=requestOptions,
837
+ )
838
+
839
+
840
+ class WebhooksClient:
841
+ def __init__(self, parent):
842
+ self._parent = parent
843
+
844
+ def get_portal_access(
845
+ self,
846
+ *,
847
+ mode,
848
+ apiKeyId=None,
849
+ requestOptions=None,
850
+ ):
851
+ return self._parent.get_webhooks_portal_access(
852
+ mode=mode,
853
+ apiKeyId=apiKeyId,
854
+ requestOptions=requestOptions,
855
+ )
856
+
857
+ def send_test_event(
858
+ self,
859
+ *,
860
+ eventType,
861
+ mode,
862
+ apiKeyId=None,
863
+ samplePayload=None,
864
+ requestOptions=None,
865
+ ):
866
+ return self._parent.send_webhook_test_event(
867
+ eventType=eventType,
868
+ mode=mode,
869
+ apiKeyId=apiKeyId,
870
+ samplePayload=samplePayload,
871
+ requestOptions=requestOptions,
872
+ )
873
+
874
+
875
+ class AudioIsolationClient:
876
+ def __init__(self, parent):
877
+ self._parent = parent
878
+
879
+ def convert(
880
+ self,
881
+ *,
882
+ filePath,
883
+ contentType=None,
884
+ contentLength=None,
885
+ fileFormat=None,
886
+ model="diffio-2",
887
+ sampling=None,
888
+ projectParams=None,
889
+ generationParams=None,
890
+ requestOptions=None,
891
+ ):
892
+ return self.isolate(
893
+ filePath=filePath,
894
+ contentType=contentType,
895
+ contentLength=contentLength,
896
+ fileFormat=fileFormat,
897
+ model=model,
898
+ sampling=sampling,
899
+ projectParams=projectParams,
900
+ generationParams=generationParams,
901
+ requestOptions=requestOptions,
902
+ )
903
+
904
+ def isolate(
905
+ self,
906
+ *,
907
+ filePath,
908
+ contentType=None,
909
+ contentLength=None,
910
+ fileFormat=None,
911
+ model="diffio-2",
912
+ sampling=None,
913
+ projectParams=None,
914
+ generationParams=None,
915
+ requestOptions=None,
916
+ ):
917
+ project = self._parent.create_project(
918
+ filePath=filePath,
919
+ contentType=contentType,
920
+ contentLength=contentLength,
921
+ params=projectParams,
922
+ fileFormat=fileFormat,
923
+ requestOptions=requestOptions,
924
+ )
925
+
926
+ generation = self._parent.create_generation(
927
+ apiProjectId=project.apiProjectId,
928
+ model=model,
929
+ sampling=sampling,
930
+ params=generationParams,
931
+ requestOptions=requestOptions,
932
+ )
933
+
934
+ return AudioIsolationResult(project=project, generation=generation)
935
+
936
+ def restore_audio(
937
+ self,
938
+ *,
939
+ filePath,
940
+ contentType=None,
941
+ contentLength=None,
942
+ fileFormat=None,
943
+ model="diffio-2",
944
+ sampling=None,
945
+ projectParams=None,
946
+ generationParams=None,
947
+ downloadType="audio",
948
+ pollInterval=2.0,
949
+ timeout=600.0,
950
+ onProgress=None,
951
+ showProgress=False,
952
+ requestOptions=None,
953
+ progressRequestOptions=None,
954
+ downloadRequestOptions=None,
955
+ raiseOnError=False,
956
+ ):
957
+ metadata = _init_restore_metadata()
958
+ metadata["downloadType"] = downloadType
959
+ resolved_progress_options = requestOptions if progressRequestOptions is None else progressRequestOptions
960
+ resolved_download_options = requestOptions if downloadRequestOptions is None else downloadRequestOptions
961
+
962
+ try:
963
+ result = self.isolate(
964
+ filePath=filePath,
965
+ contentType=contentType,
966
+ contentLength=contentLength,
967
+ fileFormat=fileFormat,
968
+ model=model,
969
+ sampling=sampling,
970
+ projectParams=projectParams,
971
+ generationParams=generationParams,
972
+ requestOptions=requestOptions,
973
+ )
974
+ except Exception as exc:
975
+ metadata["stage"] = "isolate"
976
+ _set_restore_error(metadata, exc)
977
+ if raiseOnError:
978
+ _attach_restore_metadata(exc, metadata)
979
+ raise
980
+ return None, metadata
981
+
982
+ metadata["project"] = result.project
983
+ metadata["generation"] = result.generation
984
+ metadata["apiProjectId"] = result.project.apiProjectId
985
+ metadata["generationId"] = result.generation.generationId
986
+ metadata["stage"] = "generation"
987
+
988
+ try:
989
+ progress = self._parent.wait_for_generation(
990
+ generationId=result.generation.generationId,
991
+ apiProjectId=result.project.apiProjectId,
992
+ pollInterval=pollInterval,
993
+ timeout=timeout,
994
+ onProgress=onProgress,
995
+ showProgress=showProgress,
996
+ requestOptions=resolved_progress_options,
997
+ )
998
+ except Exception as exc:
999
+ metadata["stage"] = "progress"
1000
+ progress = None
1001
+ try:
1002
+ progress = self._parent.get_generation_progress(
1003
+ generationId=result.generation.generationId,
1004
+ apiProjectId=result.project.apiProjectId,
1005
+ requestOptions=resolved_progress_options,
1006
+ )
1007
+ except Exception:
1008
+ progress = None
1009
+ metadata["progress"] = progress
1010
+ metadata["status"] = getattr(progress, "status", None)
1011
+ _set_restore_error(metadata, exc)
1012
+ if progress is not None:
1013
+ metadata["error"] = progress.error or str(exc)
1014
+ metadata["errorDetails"] = progress.errorDetails
1015
+ if raiseOnError:
1016
+ _attach_restore_metadata(exc, metadata)
1017
+ raise
1018
+ return None, metadata
1019
+
1020
+ metadata["progress"] = progress
1021
+ metadata["status"] = progress.status
1022
+ metadata["error"] = progress.error
1023
+ metadata["errorDetails"] = progress.errorDetails
1024
+
1025
+ metadata["stage"] = "download_info"
1026
+ try:
1027
+ download = self._parent.get_generation_download(
1028
+ generationId=result.generation.generationId,
1029
+ apiProjectId=result.project.apiProjectId,
1030
+ downloadType=downloadType,
1031
+ requestOptions=resolved_download_options,
1032
+ )
1033
+ except Exception as exc:
1034
+ _set_restore_error(metadata, exc)
1035
+ if raiseOnError:
1036
+ _attach_restore_metadata(exc, metadata)
1037
+ raise
1038
+ return None, metadata
1039
+
1040
+ metadata["download"] = download
1041
+ metadata["downloadType"] = download.downloadType
1042
+ metadata["downloadUrl"] = download.downloadUrl
1043
+ metadata["fileName"] = download.fileName
1044
+ metadata["mimeType"] = download.mimeType
1045
+
1046
+ metadata["stage"] = "download"
1047
+ try:
1048
+ content = _download_binary(self._parent, download.downloadUrl, requestOptions=resolved_download_options)
1049
+ except Exception as exc:
1050
+ _set_restore_error(metadata, exc)
1051
+ if raiseOnError:
1052
+ _attach_restore_metadata(exc, metadata)
1053
+ raise
1054
+ return None, metadata
1055
+
1056
+ metadata["stage"] = "complete"
1057
+ metadata["ok"] = True
1058
+ return content, metadata
1059
+
1060
+ def restore(
1061
+ self,
1062
+ *,
1063
+ filePath,
1064
+ contentType=None,
1065
+ contentLength=None,
1066
+ fileFormat=None,
1067
+ model="diffio-2",
1068
+ sampling=None,
1069
+ projectParams=None,
1070
+ generationParams=None,
1071
+ downloadType="audio",
1072
+ pollInterval=2.0,
1073
+ timeout=600.0,
1074
+ onProgress=None,
1075
+ showProgress=False,
1076
+ requestOptions=None,
1077
+ progressRequestOptions=None,
1078
+ downloadRequestOptions=None,
1079
+ raiseOnError=False,
1080
+ ):
1081
+ return self.restore_audio(
1082
+ filePath=filePath,
1083
+ contentType=contentType,
1084
+ contentLength=contentLength,
1085
+ fileFormat=fileFormat,
1086
+ model=model,
1087
+ sampling=sampling,
1088
+ projectParams=projectParams,
1089
+ generationParams=generationParams,
1090
+ downloadType=downloadType,
1091
+ pollInterval=pollInterval,
1092
+ timeout=timeout,
1093
+ onProgress=onProgress,
1094
+ showProgress=showProgress,
1095
+ requestOptions=requestOptions,
1096
+ progressRequestOptions=progressRequestOptions,
1097
+ downloadRequestOptions=downloadRequestOptions,
1098
+ raiseOnError=raiseOnError,
1099
+ )
1100
+
1101
+
1102
+
1103
+ def _guess_content_type(file_path):
1104
+ guessed, _ = mimetypes.guess_type(file_path)
1105
+ return guessed
1106
+
1107
+
1108
+ def _build_create_project_payload(
1109
+ *,
1110
+ filePath=None,
1111
+ contentType=None,
1112
+ contentLength=None,
1113
+ params=None,
1114
+ fileFormat=None,
1115
+ ):
1116
+ if filePath is None:
1117
+ raise ValueError("filePath is required")
1118
+
1119
+ resolved_path = os.fspath(filePath)
1120
+ if not resolved_path:
1121
+ raise ValueError("filePath is required")
1122
+
1123
+ resolved_file_name = os.path.basename(resolved_path)
1124
+ if not resolved_file_name:
1125
+ raise ValueError("filePath must include a file name")
1126
+
1127
+ resolved_content_type = contentType or _guess_content_type(resolved_path) or "application/octet-stream"
1128
+ resolved_content_length = contentLength
1129
+ if resolved_content_length is None:
1130
+ resolved_content_length = os.path.getsize(resolved_path)
1131
+
1132
+ payload = {
1133
+ "fileName": resolved_file_name,
1134
+ "contentType": resolved_content_type,
1135
+ "contentLength": int(resolved_content_length),
1136
+ }
1137
+ if params:
1138
+ payload["params"] = params
1139
+ if fileFormat is not None:
1140
+ payload["fileFormat"] = fileFormat
1141
+
1142
+ return payload
1143
+
1144
+
1145
+ def _normalize_download_type(download_type):
1146
+ if download_type is None:
1147
+ return None, None
1148
+ if download_type == "mp3":
1149
+ return "audio", "mp3"
1150
+ if download_type in {"audio", "video"}:
1151
+ return download_type, download_type
1152
+ raise ValueError("downloadType must be audio, mp3, or video")
1153
+
1154
+
1155
+ def _extension_from_file_name(file_name):
1156
+ if not file_name:
1157
+ return None
1158
+ extension = os.path.splitext(file_name)[1]
1159
+ if extension:
1160
+ return extension
1161
+ return None
1162
+
1163
+
1164
+ def _extension_from_mime_type(mime_type):
1165
+ if not mime_type:
1166
+ return None
1167
+ extension = mimetypes.guess_extension(mime_type)
1168
+ if extension:
1169
+ return extension
1170
+ return None
1171
+
1172
+
1173
+ def _extension_from_url(download_url):
1174
+ if not download_url:
1175
+ return None
1176
+ try:
1177
+ parsed = urlparse(download_url)
1178
+ except Exception:
1179
+ return None
1180
+ extension = os.path.splitext(parsed.path or "")[1]
1181
+ if extension:
1182
+ return extension
1183
+ return None
1184
+
1185
+
1186
+ def _expected_download_extension(download):
1187
+ if download.downloadType == "audio":
1188
+ return ".mp3"
1189
+ if download.downloadType == "video":
1190
+ return (
1191
+ _extension_from_file_name(download.fileName)
1192
+ or _extension_from_mime_type(download.mimeType)
1193
+ or _extension_from_url(download.downloadUrl)
1194
+ )
1195
+ return None
1196
+
1197
+
1198
+ def _warn_download_extension_mismatch(download, download_file_path):
1199
+ expected_extension = _expected_download_extension(download)
1200
+ if not expected_extension:
1201
+ return
1202
+ provided_extension = os.path.splitext(download_file_path)[1]
1203
+ if provided_extension.lower() != expected_extension.lower():
1204
+ warnings.warn(
1205
+ "downloadFilePath should end with "
1206
+ f"{expected_extension} for {download.downloadType} downloads. "
1207
+ f"Received {download_file_path}.",
1208
+ UserWarning,
1209
+ stacklevel=2,
1210
+ )
1211
+
1212
+
1213
+ def _is_storage_emulator_url(upload_url):
1214
+ try:
1215
+ parsed = urlparse(upload_url)
1216
+ except Exception:
1217
+ return False
1218
+
1219
+ host = (parsed.hostname or "").lower()
1220
+ port = parsed.port
1221
+ if host in {"127.0.0.1", "localhost", "0.0.0.0", "::1"} and (port == 9199 or port is None):
1222
+ return True
1223
+
1224
+ emulator_host = os.environ.get("STORAGE_EMULATOR_HOST") or os.environ.get("FIREBASE_STORAGE_EMULATOR_HOST")
1225
+ if not emulator_host:
1226
+ return False
1227
+ if not emulator_host.startswith(("http://", "https://")):
1228
+ emulator_host = f"http://{emulator_host}"
1229
+ try:
1230
+ emulator_parsed = urlparse(emulator_host)
1231
+ except Exception:
1232
+ return False
1233
+ if (parsed.hostname or "").lower() != (emulator_parsed.hostname or "").lower():
1234
+ return False
1235
+
1236
+ parsed_port = parsed.port or (443 if parsed.scheme == "https" else 80)
1237
+ emulator_port = emulator_parsed.port or (443 if emulator_parsed.scheme == "https" else 80)
1238
+ return parsed_port == emulator_port
1239
+
1240
+
1241
+ def _merge_headers(base_headers, override_headers):
1242
+ headers = {}
1243
+ if base_headers:
1244
+ headers.update(base_headers)
1245
+ if override_headers:
1246
+ headers.update(override_headers)
1247
+ return headers
1248
+
1249
+
1250
+ def _sleep_retry(attempt, retry_backoff):
1251
+ if retry_backoff is None:
1252
+ return
1253
+ delay = retry_backoff * (2 ** attempt)
1254
+ if delay > 0:
1255
+ time.sleep(delay)
1256
+
1257
+
1258
+ def _raise_for_error(response):
1259
+ if 200 <= response.status_code < 300:
1260
+ content_type = response.headers.get("Content-Type", "")
1261
+ if response.content and "application/json" in content_type:
1262
+ return response.json()
1263
+ return {}
1264
+
1265
+ message = f"Request failed with status {response.status_code}"
1266
+ body = None
1267
+ try:
1268
+ if response.content:
1269
+ body = response.json()
1270
+ if isinstance(body, dict) and body.get("error"):
1271
+ message = str(body.get("error"))
1272
+ else:
1273
+ body = None
1274
+ except json.JSONDecodeError:
1275
+ body = response.text
1276
+
1277
+ raise DiffioApiError(message, statusCode=response.status_code, responseBody=body)
1278
+
1279
+
1280
+ def _init_restore_metadata():
1281
+ return {
1282
+ "ok": False,
1283
+ "stage": "start",
1284
+ "apiProjectId": None,
1285
+ "generationId": None,
1286
+ "project": None,
1287
+ "generation": None,
1288
+ "progress": None,
1289
+ "download": None,
1290
+ "downloadType": None,
1291
+ "downloadUrl": None,
1292
+ "fileName": None,
1293
+ "mimeType": None,
1294
+ "status": None,
1295
+ "error": None,
1296
+ "errorDetails": None,
1297
+ "exceptionType": None,
1298
+ "exceptionMessage": None,
1299
+ }
1300
+
1301
+
1302
+ def _set_restore_error(metadata, exc):
1303
+ metadata["error"] = str(exc)
1304
+ metadata["exceptionType"] = exc.__class__.__name__
1305
+ metadata["exceptionMessage"] = str(exc)
1306
+
1307
+
1308
+ def _attach_restore_metadata(exc, metadata):
1309
+ try:
1310
+ setattr(exc, "restoreInfo", metadata)
1311
+ except Exception:
1312
+ return
1313
+
1314
+
1315
+ def _download_binary(parent, download_url, *, requestOptions=None):
1316
+ merged_options = _merge_request_options(parent._default_request_options, requestOptions)
1317
+ headers = {}
1318
+ if _is_storage_emulator_url(download_url):
1319
+ headers["Authorization"] = "Bearer owner"
1320
+ headers = _merge_headers(headers, merged_options.headers)
1321
+ timeout = merged_options.timeout
1322
+ max_retries = merged_options.maxRetries if merged_options.maxRetries is not None else 0
1323
+ retry_backoff = (
1324
+ merged_options.retryBackoff if merged_options.retryBackoff is not None else DEFAULT_RETRY_BACKOFF
1325
+ )
1326
+ retry_statuses = (
1327
+ merged_options.retryStatusCodes
1328
+ if merged_options.retryStatusCodes is not None
1329
+ else DEFAULT_RETRY_STATUS_CODES
1330
+ )
1331
+
1332
+ attempt = 0
1333
+ while True:
1334
+ try:
1335
+ request_kwargs = {
1336
+ "method": "GET",
1337
+ "url": download_url,
1338
+ "headers": headers,
1339
+ }
1340
+ if timeout is not None:
1341
+ request_kwargs["timeout"] = timeout
1342
+ response = parent._client.request(**request_kwargs)
1343
+ except httpx.RequestError:
1344
+ if attempt >= max_retries:
1345
+ raise
1346
+ _sleep_retry(attempt, retry_backoff)
1347
+ attempt += 1
1348
+ continue
1349
+
1350
+ if retry_statuses and response.status_code in retry_statuses and attempt < max_retries:
1351
+ response.close()
1352
+ _sleep_retry(attempt, retry_backoff)
1353
+ attempt += 1
1354
+ continue
1355
+
1356
+ try:
1357
+ if 200 <= response.status_code < 300:
1358
+ return response.content
1359
+ _raise_for_error(response)
1360
+ finally:
1361
+ response.close()
1362
+
1363
+
1364
+ def _download_to_file(parent, download_url, file_path, *, requestOptions=None):
1365
+ merged_options = _merge_request_options(parent._default_request_options, requestOptions)
1366
+ headers = {}
1367
+ if _is_storage_emulator_url(download_url):
1368
+ headers["Authorization"] = "Bearer owner"
1369
+ headers = _merge_headers(headers, merged_options.headers)
1370
+ timeout = merged_options.timeout
1371
+ max_retries = merged_options.maxRetries if merged_options.maxRetries is not None else 0
1372
+ retry_backoff = (
1373
+ merged_options.retryBackoff if merged_options.retryBackoff is not None else DEFAULT_RETRY_BACKOFF
1374
+ )
1375
+ retry_statuses = (
1376
+ merged_options.retryStatusCodes
1377
+ if merged_options.retryStatusCodes is not None
1378
+ else DEFAULT_RETRY_STATUS_CODES
1379
+ )
1380
+
1381
+ resolved_path = os.fspath(file_path)
1382
+ directory = os.path.dirname(resolved_path) or "."
1383
+ temp_handle = None
1384
+ temp_path = None
1385
+
1386
+ try:
1387
+ attempt = 0
1388
+ while True:
1389
+ try:
1390
+ fd, temp_path = tempfile.mkstemp(prefix="diffio-download-", dir=directory)
1391
+ temp_handle = os.fdopen(fd, "wb")
1392
+ with parent._client.stream("GET", download_url, headers=headers, timeout=timeout) as response:
1393
+ if retry_statuses and response.status_code in retry_statuses and attempt < max_retries:
1394
+ temp_handle.close()
1395
+ try:
1396
+ os.remove(temp_path)
1397
+ except OSError:
1398
+ pass
1399
+ _sleep_retry(attempt, retry_backoff)
1400
+ attempt += 1
1401
+ continue
1402
+ if not (200 <= response.status_code < 300):
1403
+ temp_handle.close()
1404
+ try:
1405
+ os.remove(temp_path)
1406
+ except OSError:
1407
+ pass
1408
+ _raise_for_error(response)
1409
+ for chunk in response.iter_bytes():
1410
+ if chunk:
1411
+ temp_handle.write(chunk)
1412
+ temp_handle.close()
1413
+ temp_handle = None
1414
+ os.replace(temp_path, resolved_path)
1415
+ temp_path = None
1416
+ return
1417
+ except httpx.RequestError:
1418
+ if temp_handle is not None:
1419
+ temp_handle.close()
1420
+ temp_handle = None
1421
+ if temp_path is not None:
1422
+ try:
1423
+ os.remove(temp_path)
1424
+ except OSError:
1425
+ pass
1426
+ temp_path = None
1427
+ if attempt >= max_retries:
1428
+ raise
1429
+ _sleep_retry(attempt, retry_backoff)
1430
+ attempt += 1
1431
+ continue
1432
+ finally:
1433
+ if temp_handle is not None:
1434
+ temp_handle.close()
1435
+ if temp_path is not None:
1436
+ try:
1437
+ os.remove(temp_path)
1438
+ except OSError:
1439
+ pass
1440
+
1441
+
1442
+ def _format_progress(progress):
1443
+ parts = []
1444
+ if progress.preProcessing is not None:
1445
+ parts.append(f"pre={progress.preProcessing.status}:{progress.preProcessing.progress}%")
1446
+ if progress.inference is not None:
1447
+ parts.append(f"inf={progress.inference.status}:{progress.inference.progress}%")
1448
+ if getattr(progress, "restoredVideo", None) is not None:
1449
+ parts.append(f"vid={progress.restoredVideo.status}:{progress.restoredVideo.progress}%")
1450
+ joined = ", ".join(parts)
1451
+ if joined:
1452
+ return f"{progress.status} ({joined})"
1453
+ return f"{progress.status}"
1454
+
1455
+
1456
+ def _report_progress(progress, *, onProgress, showProgress):
1457
+ if onProgress:
1458
+ onProgress(progress)
1459
+ if showProgress:
1460
+ print(_format_progress(progress))