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/errors.py ADDED
@@ -0,0 +1,15 @@
1
+ class DiffioApiError(Exception):
2
+ def __init__(self, message, statusCode=None, responseBody=None):
3
+ super().__init__(message)
4
+ self.message = message
5
+ self.statusCode = statusCode
6
+ self.responseBody = responseBody
7
+
8
+ def __str__(self):
9
+ details = []
10
+ if self.statusCode is not None:
11
+ details.append(f"statusCode={self.statusCode}")
12
+ if self.responseBody is not None:
13
+ details.append("responseBody set")
14
+ suffix = f" ({', '.join(details)})" if details else ""
15
+ return f"{self.message}{suffix}"
diffio/testing.py ADDED
@@ -0,0 +1,296 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import secrets
5
+ from urllib.parse import urlparse
6
+
7
+ import httpx
8
+
9
+ ALLOWED_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
10
+ DEFAULT_PROJECT_ID = "diffioai"
11
+ DEFAULT_AUTH_HOST = "127.0.0.1:9099"
12
+ DEFAULT_FUNCTIONS_HOST = "127.0.0.1:5001"
13
+ DEFAULT_WEB_API_KEY = "fake-api-key"
14
+ DEFAULT_REGION = "us-central1"
15
+
16
+
17
+ class EmulatorApiKeyResult:
18
+ def __init__(self, api_key, key_id, key_prefix, label, user_id, email, password, id_token):
19
+ self.api_key = api_key
20
+ self.key_id = key_id
21
+ self.key_prefix = key_prefix
22
+ self.label = label
23
+ self.user_id = user_id
24
+ self.email = email
25
+ self.password = password
26
+ self.id_token = id_token
27
+
28
+
29
+ class EmulatorApiKeyError(RuntimeError):
30
+ def __init__(self, message, status_code=None, payload=None, code=None):
31
+ super().__init__(message)
32
+ self.status_code = status_code
33
+ self.payload = payload
34
+ self.code = code
35
+
36
+
37
+ def _normalize_origin(raw_host, default_port, label):
38
+ host = (raw_host or "").strip()
39
+ if not host:
40
+ raise ValueError(f"Missing {label} host.")
41
+ if not host.startswith(("http://", "https://")):
42
+ host = f"http://{host}"
43
+ parsed = urlparse(host)
44
+ hostname = parsed.hostname
45
+ if not hostname:
46
+ raise ValueError(f"Invalid {label} host {raw_host}.")
47
+ if hostname not in ALLOWED_LOCAL_HOSTS:
48
+ raise ValueError(
49
+ f"Refusing to contact non-local {label} host {hostname}. Set the emulator host to localhost."
50
+ )
51
+ port = parsed.port or default_port
52
+ origin = f"{parsed.scheme}://{hostname}:{port}"
53
+ return origin, hostname
54
+
55
+
56
+ def _parse_error(payload):
57
+ if not isinstance(payload, dict):
58
+ return None, None
59
+ error = payload.get("error")
60
+ if not isinstance(error, dict):
61
+ return None, None
62
+ message = error.get("message") or error.get("status")
63
+ return message, error
64
+
65
+
66
+ def _request_json(
67
+ client,
68
+ *,
69
+ method,
70
+ url,
71
+ headers=None,
72
+ json_body=None,
73
+ ):
74
+ response = client.request(method, url, headers=headers, json=json_body)
75
+ payload = None
76
+ try:
77
+ payload = response.json()
78
+ except ValueError:
79
+ payload = None
80
+ error_code, _ = _parse_error(payload)
81
+ if response.status_code >= 400 or error_code:
82
+ message = error_code or response.text or f"Request failed with {response.status_code}."
83
+ raise EmulatorApiKeyError(message, status_code=response.status_code, payload=payload, code=error_code)
84
+ if not isinstance(payload, dict):
85
+ raise EmulatorApiKeyError("Unexpected response payload.", status_code=response.status_code, payload=payload)
86
+ return payload
87
+
88
+
89
+ def _create_or_sign_in_user(
90
+ client,
91
+ *,
92
+ identity_base,
93
+ email,
94
+ password,
95
+ web_api_key,
96
+ allow_existing,
97
+ ):
98
+ signup_url = f"{identity_base}/accounts:signUp?key={web_api_key}"
99
+ payload = {"email": email, "password": password, "returnSecureToken": True}
100
+ try:
101
+ data = _request_json(client, method="POST", url=signup_url, json_body=payload)
102
+ except EmulatorApiKeyError as exc:
103
+ if exc.code == "EMAIL_EXISTS":
104
+ if not allow_existing:
105
+ raise EmulatorApiKeyError(
106
+ "Email already exists. Provide the correct password to sign in.",
107
+ status_code=exc.status_code,
108
+ payload=exc.payload,
109
+ code=exc.code,
110
+ ) from exc
111
+ signin_url = f"{identity_base}/accounts:signInWithPassword?key={web_api_key}"
112
+ data = _request_json(client, method="POST", url=signin_url, json_body=payload)
113
+ else:
114
+ raise
115
+ uid = data.get("localId")
116
+ id_token = data.get("idToken")
117
+ if not uid or not id_token:
118
+ raise EmulatorApiKeyError("Auth emulator response missing uid or idToken.", payload=data)
119
+ return uid, id_token
120
+
121
+
122
+ def create_emulator_api_key(
123
+ *,
124
+ label=None,
125
+ email=None,
126
+ password=None,
127
+ project_id=None,
128
+ auth_emulator_host=None,
129
+ functions_emulator_host=None,
130
+ web_api_key=None,
131
+ region=None,
132
+ is_restricted=False,
133
+ spend_limit=None,
134
+ permissions=None,
135
+ http_client=None,
136
+ ):
137
+ """Create an emulator API key linked to an emulator Auth user."""
138
+ project_id = project_id or os.environ.get("FIREBASE_PROJECT_ID", DEFAULT_PROJECT_ID)
139
+ region = region or DEFAULT_REGION
140
+ auth_host = auth_emulator_host or os.environ.get("FIREBASE_AUTH_EMULATOR_HOST", DEFAULT_AUTH_HOST)
141
+ functions_host = functions_emulator_host or os.environ.get(
142
+ "FUNCTIONS_EMULATOR_HOST",
143
+ os.environ.get("FIREBASE_FUNCTIONS_EMULATOR_HOST", DEFAULT_FUNCTIONS_HOST),
144
+ )
145
+ web_api_key = web_api_key or os.environ.get("FIREBASE_WEB_API_KEY", DEFAULT_WEB_API_KEY)
146
+
147
+ auth_origin, _ = _normalize_origin(auth_host, 9099, "Auth emulator")
148
+ functions_origin, _ = _normalize_origin(functions_host, 5001, "Functions emulator")
149
+
150
+ label = label or f"test-key-{secrets.token_hex(4)}"
151
+ email = email or f"test-{secrets.token_hex(4)}@example.com"
152
+ password_provided = password is not None
153
+ password = password or secrets.token_urlsafe(12)
154
+
155
+ if is_restricted or spend_limit is not None:
156
+ if spend_limit is None:
157
+ raise ValueError("spendLimit is required when restricted.")
158
+ is_restricted = True
159
+
160
+ owns_client = http_client is None
161
+ client = http_client or httpx.Client(timeout=10.0)
162
+
163
+ try:
164
+ identity_base = f"{auth_origin}/identitytoolkit.googleapis.com/v1"
165
+ uid, id_token = _create_or_sign_in_user(
166
+ client,
167
+ identity_base=identity_base,
168
+ email=email,
169
+ password=password,
170
+ web_api_key=web_api_key,
171
+ allow_existing=password_provided,
172
+ )
173
+
174
+ data_payload = {"label": label}
175
+ if is_restricted:
176
+ data_payload["isRestricted"] = True
177
+ data_payload["spendLimit"] = spend_limit
178
+ if permissions is not None:
179
+ data_payload["permissions"] = permissions
180
+
181
+ function_url = f"{functions_origin}/{project_id}/{region}/create_api_key"
182
+ response = _request_json(
183
+ client,
184
+ method="POST",
185
+ url=function_url,
186
+ headers={"Authorization": f"Bearer {id_token}"},
187
+ json_body={"data": data_payload},
188
+ )
189
+ result = response.get("result") or response.get("data") or response
190
+ if not isinstance(result, dict):
191
+ raise EmulatorApiKeyError("Callable response missing result data.", payload=response)
192
+ api_key = result.get("key")
193
+ key_id = result.get("keyId")
194
+ key_prefix = result.get("keyPrefix")
195
+ if not api_key or not key_id:
196
+ raise EmulatorApiKeyError("Callable response missing key data.", payload=result)
197
+
198
+ return EmulatorApiKeyResult(
199
+ api_key=api_key,
200
+ key_id=key_id,
201
+ key_prefix=key_prefix or "",
202
+ label=str(result.get("label") or label),
203
+ user_id=uid,
204
+ email=email,
205
+ password=password,
206
+ id_token=id_token,
207
+ )
208
+ finally:
209
+ if owns_client:
210
+ client.close()
211
+
212
+
213
+ def _build_arg_parser():
214
+ parser = argparse.ArgumentParser(
215
+ description="Create an emulator API key and link it to an emulator Auth user.",
216
+ )
217
+ parser.add_argument("--label", help="Label for the API key (defaults to a random test label).")
218
+ parser.add_argument("--email", help="Email for the Auth emulator user (defaults to random).")
219
+ parser.add_argument("--password", help="Password for the Auth emulator user (defaults to random).")
220
+ parser.add_argument("--projectId", help=f"Firebase project id (default {DEFAULT_PROJECT_ID}).")
221
+ parser.add_argument("--authHost", help=f"Auth emulator host (default {DEFAULT_AUTH_HOST}).")
222
+ parser.add_argument("--functionsHost", help=f"Functions emulator host (default {DEFAULT_FUNCTIONS_HOST}).")
223
+ parser.add_argument("--webApiKey", help=f"Web API key (default {DEFAULT_WEB_API_KEY}).")
224
+ parser.add_argument("--region", help=f"Functions region (default {DEFAULT_REGION}).")
225
+ parser.add_argument("--restricted", action="store_true", help="Create a restricted key with spendLimit.")
226
+ parser.add_argument("--spendLimit", type=float, help="Spend limit for restricted keys.")
227
+ parser.add_argument(
228
+ "--permissions",
229
+ help='Optional permissions JSON, for example {"read": true, "write": false}.',
230
+ )
231
+ parser.add_argument("--json", action="store_true", help="Print JSON output for scripting.")
232
+ parser.add_argument("--show-id-token", action="store_true", help="Include the idToken in output.")
233
+ parser.add_argument("--show-password", action="store_true", help="Include the password in output.")
234
+ return parser
235
+
236
+
237
+ def _format_result(
238
+ result,
239
+ *,
240
+ show_password,
241
+ show_id_token,
242
+ ):
243
+ payload = {
244
+ "apiKey": result.api_key,
245
+ "keyId": result.key_id,
246
+ "keyPrefix": result.key_prefix,
247
+ "label": result.label,
248
+ "userId": result.user_id,
249
+ "email": result.email,
250
+ }
251
+ if show_password:
252
+ payload["password"] = result.password
253
+ if show_id_token:
254
+ payload["idToken"] = result.id_token
255
+ return payload
256
+
257
+
258
+ def main():
259
+ parser = _build_arg_parser()
260
+ args = parser.parse_args()
261
+
262
+ permissions = None
263
+ if args.permissions:
264
+ try:
265
+ permissions = json.loads(args.permissions)
266
+ except json.JSONDecodeError as exc:
267
+ raise SystemExit(f"permissions must be valid JSON. {exc}") from exc
268
+
269
+ result = create_emulator_api_key(
270
+ label=args.label,
271
+ email=args.email,
272
+ password=args.password,
273
+ project_id=args.projectId,
274
+ auth_emulator_host=args.authHost,
275
+ functions_emulator_host=args.functionsHost,
276
+ web_api_key=args.webApiKey,
277
+ region=args.region,
278
+ is_restricted=args.restricted,
279
+ spend_limit=args.spendLimit,
280
+ permissions=permissions,
281
+ )
282
+
283
+ payload = _format_result(result, show_password=args.show_password, show_id_token=args.show_id_token)
284
+
285
+ if args.json:
286
+ print(json.dumps(payload, indent=2))
287
+ return 0
288
+
289
+ print("Created emulator API key.")
290
+ for key, value in payload.items():
291
+ print(f"{key}: {value}")
292
+ return 0
293
+
294
+
295
+ if __name__ == "__main__":
296
+ raise SystemExit(main())
diffio/types.py ADDED
@@ -0,0 +1,333 @@
1
+ class CreateProjectResponse:
2
+ def __init__(
3
+ self,
4
+ apiProjectId,
5
+ uploadUrl,
6
+ uploadMethod,
7
+ objectPath,
8
+ bucket,
9
+ expiresAt,
10
+ ):
11
+ self.apiProjectId = apiProjectId
12
+ self.uploadUrl = uploadUrl
13
+ self.uploadMethod = uploadMethod
14
+ self.objectPath = objectPath
15
+ self.bucket = bucket
16
+ self.expiresAt = expiresAt
17
+
18
+ @classmethod
19
+ def from_dict(cls, data):
20
+ return cls(
21
+ apiProjectId=data["apiProjectId"],
22
+ uploadUrl=data["uploadUrl"],
23
+ uploadMethod=data.get("uploadMethod") or "PUT",
24
+ objectPath=data["objectPath"],
25
+ bucket=data["bucket"],
26
+ expiresAt=data["expiresAt"],
27
+ )
28
+
29
+
30
+ class ProjectSummary:
31
+ def __init__(
32
+ self,
33
+ apiProjectId,
34
+ status,
35
+ originalFileName,
36
+ contentType,
37
+ hasVideo,
38
+ generationCount,
39
+ createdAt,
40
+ updatedAt,
41
+ ):
42
+ self.apiProjectId = apiProjectId
43
+ self.status = status
44
+ self.originalFileName = originalFileName
45
+ self.contentType = contentType
46
+ self.hasVideo = hasVideo
47
+ self.generationCount = generationCount
48
+ self.createdAt = createdAt
49
+ self.updatedAt = updatedAt
50
+
51
+ @classmethod
52
+ def from_dict(cls, data):
53
+ return cls(
54
+ apiProjectId=data["apiProjectId"],
55
+ status=data.get("status") or "uploading",
56
+ originalFileName=data.get("originalFileName"),
57
+ contentType=data.get("contentType"),
58
+ hasVideo=bool(data.get("hasVideo")),
59
+ generationCount=int(data.get("generationCount") or 0),
60
+ createdAt=data.get("createdAt"),
61
+ updatedAt=data.get("updatedAt"),
62
+ )
63
+
64
+
65
+ class ListProjectsResponse:
66
+ def __init__(self, projects):
67
+ self.projects = projects
68
+
69
+ @classmethod
70
+ def from_dict(cls, data):
71
+ items = data.get("projects")
72
+ if not isinstance(items, list):
73
+ items = []
74
+ projects = [ProjectSummary.from_dict(item) for item in items if isinstance(item, dict)]
75
+ return cls(projects=projects)
76
+
77
+
78
+ class CreateGenerationResponse:
79
+ def __init__(self, generationId, apiProjectId, modelKey, status):
80
+ self.generationId = generationId
81
+ self.apiProjectId = apiProjectId
82
+ self.modelKey = modelKey
83
+ self.status = status
84
+
85
+ @classmethod
86
+ def from_dict(cls, data):
87
+ return cls(
88
+ generationId=data["generationId"],
89
+ apiProjectId=data["apiProjectId"],
90
+ modelKey=data["modelKey"],
91
+ status=data["status"],
92
+ )
93
+
94
+
95
+ class ProjectGenerationSummary:
96
+ def __init__(self, generationId, status, modelKey, progress, createdAt, updatedAt):
97
+ self.generationId = generationId
98
+ self.status = status
99
+ self.modelKey = modelKey
100
+ self.progress = progress
101
+ self.createdAt = createdAt
102
+ self.updatedAt = updatedAt
103
+
104
+ @classmethod
105
+ def from_dict(cls, data):
106
+ progress = data.get("progress")
107
+ return cls(
108
+ generationId=data["generationId"],
109
+ status=data.get("status") or "queued",
110
+ modelKey=data.get("modelKey"),
111
+ progress=int(progress) if progress is not None else None,
112
+ createdAt=data.get("createdAt"),
113
+ updatedAt=data.get("updatedAt"),
114
+ )
115
+
116
+
117
+ class ListProjectGenerationsResponse:
118
+ def __init__(self, apiProjectId, generations):
119
+ self.apiProjectId = apiProjectId
120
+ self.generations = generations
121
+
122
+ @classmethod
123
+ def from_dict(cls, data):
124
+ items = data.get("generations")
125
+ if not isinstance(items, list):
126
+ items = []
127
+ generations = [ProjectGenerationSummary.from_dict(item) for item in items if isinstance(item, dict)]
128
+ return cls(
129
+ apiProjectId=data["apiProjectId"],
130
+ generations=generations,
131
+ )
132
+
133
+
134
+ class GenerationProgressStage:
135
+ def __init__(
136
+ self,
137
+ jobId,
138
+ jobState,
139
+ status,
140
+ progress,
141
+ statusMessage,
142
+ error,
143
+ errorDetails,
144
+ ):
145
+ self.jobId = jobId
146
+ self.jobState = jobState
147
+ self.status = status
148
+ self.progress = progress
149
+ self.statusMessage = statusMessage
150
+ self.error = error
151
+ self.errorDetails = errorDetails
152
+
153
+ @classmethod
154
+ def from_dict(cls, data):
155
+ return cls(
156
+ jobId=data.get("jobId"),
157
+ jobState=data.get("jobState"),
158
+ status=data.get("status") or "pending",
159
+ progress=int(data.get("progress") or 0),
160
+ statusMessage=data.get("statusMessage"),
161
+ error=data.get("error"),
162
+ errorDetails=data.get("errorDetails"),
163
+ )
164
+
165
+
166
+ class GenerationProgressResponse:
167
+ def __init__(
168
+ self,
169
+ generationId,
170
+ apiProjectId,
171
+ status,
172
+ hasVideo,
173
+ preProcessing,
174
+ inference,
175
+ restoredVideo,
176
+ error,
177
+ errorDetails,
178
+ ):
179
+ self.generationId = generationId
180
+ self.apiProjectId = apiProjectId
181
+ self.status = status
182
+ self.hasVideo = hasVideo
183
+ self.preProcessing = preProcessing
184
+ self.inference = inference
185
+ self.restoredVideo = restoredVideo
186
+ self.error = error
187
+ self.errorDetails = errorDetails
188
+
189
+ @classmethod
190
+ def from_dict(cls, data):
191
+ restored_video = data.get("restoredVideo")
192
+ return cls(
193
+ generationId=data["generationId"],
194
+ apiProjectId=data["apiProjectId"],
195
+ status=data["status"],
196
+ hasVideo=bool(data.get("hasVideo")),
197
+ preProcessing=GenerationProgressStage.from_dict(data["preProcessing"]),
198
+ inference=GenerationProgressStage.from_dict(data["inference"]),
199
+ restoredVideo=(GenerationProgressStage.from_dict(restored_video) if restored_video else None),
200
+ error=data.get("error"),
201
+ errorDetails=data.get("errorDetails"),
202
+ )
203
+
204
+
205
+ class GenerationDownloadResponse:
206
+ def __init__(
207
+ self,
208
+ generationId,
209
+ apiProjectId,
210
+ downloadType,
211
+ downloadUrl,
212
+ fileName,
213
+ storagePath,
214
+ bucket,
215
+ mimeType,
216
+ ):
217
+ self.generationId = generationId
218
+ self.apiProjectId = apiProjectId
219
+ self.downloadType = downloadType
220
+ self.downloadUrl = downloadUrl
221
+ self.fileName = fileName
222
+ self.storagePath = storagePath
223
+ self.bucket = bucket
224
+ self.mimeType = mimeType
225
+
226
+ @classmethod
227
+ def from_dict(cls, data):
228
+ return cls(
229
+ generationId=data["generationId"],
230
+ apiProjectId=data["apiProjectId"],
231
+ downloadType=data["downloadType"],
232
+ downloadUrl=data["downloadUrl"],
233
+ fileName=data["fileName"],
234
+ storagePath=data["storagePath"],
235
+ bucket=data["bucket"],
236
+ mimeType=data["mimeType"],
237
+ )
238
+
239
+
240
+ class WebhookPortalResponse:
241
+ def __init__(self, portalUrl, mode, apiKeyId=None):
242
+ self.portalUrl = portalUrl
243
+ self.mode = mode
244
+ self.apiKeyId = apiKeyId
245
+
246
+ @classmethod
247
+ def from_dict(cls, data):
248
+ return cls(
249
+ portalUrl=data["portalUrl"],
250
+ mode=data.get("mode"),
251
+ apiKeyId=data.get("apiKeyId"),
252
+ )
253
+
254
+
255
+ class WebhookTestEventResponse:
256
+ def __init__(self, svixMessageId, eventId, eventType, mode, apiKeyId=None):
257
+ self.svixMessageId = svixMessageId
258
+ self.eventId = eventId
259
+ self.eventType = eventType
260
+ self.mode = mode
261
+ self.apiKeyId = apiKeyId
262
+
263
+ @classmethod
264
+ def from_dict(cls, data):
265
+ return cls(
266
+ svixMessageId=data["svixMessageId"],
267
+ eventId=data["eventId"],
268
+ eventType=data["eventType"],
269
+ mode=data.get("mode"),
270
+ apiKeyId=data.get("apiKeyId"),
271
+ )
272
+
273
+
274
+ class GenerationWebhookEvent:
275
+ def __init__(
276
+ self,
277
+ eventType,
278
+ eventId,
279
+ createdAt,
280
+ apiKeyId,
281
+ apiProjectId,
282
+ generationId,
283
+ status,
284
+ hasVideo,
285
+ modelKey,
286
+ error,
287
+ errorDetails,
288
+ ):
289
+ self.eventType = eventType
290
+ self.eventId = eventId
291
+ self.createdAt = createdAt
292
+ self.apiKeyId = apiKeyId
293
+ self.apiProjectId = apiProjectId
294
+ self.generationId = generationId
295
+ self.status = status
296
+ self.hasVideo = hasVideo
297
+ self.modelKey = modelKey
298
+ self.error = error
299
+ self.errorDetails = errorDetails
300
+
301
+ @classmethod
302
+ def from_dict(cls, data):
303
+ return cls(
304
+ eventType=data["eventType"],
305
+ eventId=data["eventId"],
306
+ createdAt=data["createdAt"],
307
+ apiKeyId=data["apiKeyId"],
308
+ apiProjectId=data.get("apiProjectId"),
309
+ generationId=data["generationId"],
310
+ status=data["status"],
311
+ hasVideo=data.get("hasVideo"),
312
+ modelKey=data.get("modelKey"),
313
+ error=data.get("error"),
314
+ errorDetails=data.get("errorDetails"),
315
+ )
316
+
317
+
318
+ class AudioIsolationResult:
319
+ def __init__(self, project, generation):
320
+ self.project = project
321
+ self.generation = generation
322
+
323
+
324
+ ModelKey = ("diffio-2", "diffio-2-flash", "diffio-3")
325
+ DownloadType = ("audio", "mp3", "video")
326
+ WebhookMode = ("test", "live")
327
+ WebhookEventType = (
328
+ "generation.queued",
329
+ "generation.processing",
330
+ "generation.failed",
331
+ "generation.completed",
332
+ )
333
+ GenerationWebhookStatus = ("queued", "processing", "error", "complete")