sprocket-systems.coda.sdk 2.0.8__py3-none-any.whl → 2.0.11__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.
coda/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # The versions below will be replaced automatically in CI.
2
2
  # You do not need to modify any of the versions below.
3
- __version__ = "2.0.8"
4
- CODA_APP_SUITE_VERSION = "+coda-2.0.13"
3
+ __version__ = "2.0.11"
4
+ CODA_APP_SUITE_VERSION = "+coda-2.0.14"
5
5
  FINAL_VERSION = __version__ + CODA_APP_SUITE_VERSION
coda/sdk/__init__.py CHANGED
@@ -4,6 +4,15 @@ from .workflow import WorkflowDefinition, WorkflowDefinitionBuilder
4
4
  from .preset import Preset
5
5
  from .enums import PresetType, SourceType, VenueType, InputFilter, Language, Format, StemType, FrameRate, InputStemType
6
6
  from .utils import user_info, timing_info, get_channels
7
+ from .exceptions import (
8
+ CodaAPIError,
9
+ CodaAuthenticationError,
10
+ CodaForbiddenError,
11
+ CodaBadRequestError,
12
+ CodaNotFoundError,
13
+ CodaClientError,
14
+ CodaServerError,
15
+ )
7
16
 
8
17
  __all__ = [
9
18
  "Job",
@@ -24,4 +33,11 @@ __all__ = [
24
33
  "user_info",
25
34
  "get_channels",
26
35
  "timing_info",
36
+ "CodaAPIError",
37
+ "CodaAuthenticationError",
38
+ "CodaForbiddenError",
39
+ "CodaBadRequestError",
40
+ "CodaNotFoundError",
41
+ "CodaClientError",
42
+ "CodaServerError",
27
43
  ]
coda/sdk/essence.py CHANGED
@@ -1,3 +1,4 @@
1
+ from numbers import Number
1
2
  import os
2
3
  import sys
3
4
  import json
@@ -5,8 +6,8 @@ import shutil
5
6
  import subprocess
6
7
 
7
8
  from pathlib import Path
8
- from typing import List, Dict
9
- from .enums import Format, SourceType, InputStemType
9
+ from typing import List, Dict, Any
10
+ from .enums import Format, SourceType, InputStemType, FrameRate, Language
10
11
  from .constants import (
11
12
  ENV_CODA_CLI_EXE,
12
13
  ENV_NO_CODA_EXE,
@@ -58,17 +59,26 @@ class Essence:
58
59
  if not format or not isinstance(format, str):
59
60
  raise ValueError("format must not be an empty string and must be a string type.")
60
61
 
61
- self.payload = {
62
+ self.payload: Dict[str, Any] = {
62
63
  "type": "",
63
64
  "definition": {
64
65
  "format": format,
65
66
  "program": program,
66
67
  "description": description,
67
68
  "type": stem_type,
68
- },
69
- "timing_info": timing_info
69
+ }
70
70
  }
71
71
 
72
+ if timing_info is not None:
73
+ if timing_info.get("frame_rate"):
74
+ self.payload["definition"]["frame_rate"] = timing_info.get("frame_rate")
75
+ if timing_info.get("ffoa_timecode"):
76
+ self.payload["definition"]["ffoa_timecode"] = timing_info.get("ffoa_timecode")
77
+ if timing_info.get("lfoa_timecode"):
78
+ self.payload["definition"]["lfoa_timecode"] = timing_info.get("lfoa_timecode")
79
+ if timing_info.get("file_start_timecode"):
80
+ self.payload["definition"]["file_start_timecode"] = timing_info.get("file_start_timecode")
81
+
72
82
  def add_interleaved_resource(
73
83
  self,
74
84
  file: str | dict,
@@ -119,7 +129,7 @@ class Essence:
119
129
  raise ValueError("IO Location ID is required for non-S3 file sources.")
120
130
  url = f"{URL_PREFIX_IO}{io_location_id}{url}"
121
131
 
122
- resource_dict = {"url": url}
132
+ resource_dict: Dict[str, Any] = {"url": url}
123
133
  if auth is not None:
124
134
  resource_dict["auth"] = auth
125
135
  if opts is not None:
@@ -206,7 +216,7 @@ class Essence:
206
216
  raise ValueError("IO Location ID is required for non-S3 file sources.")
207
217
  url = f"{URL_PREFIX_IO}{io_location_id}{url}"
208
218
 
209
- res = {
219
+ res: Dict[str, Any] = {
210
220
  "resource": {"url": url},
211
221
  "bit_depth": bit_depth,
212
222
  "sample_rate": sample_rate,
@@ -249,7 +259,7 @@ class Essence:
249
259
  """
250
260
  self.payload["definition"]["format"] = format
251
261
 
252
- def override_language(self, language) -> None:
262
+ def override_language(self, language: Language) -> None:
253
263
  """Override the language of the essence.
254
264
 
255
265
  Args:
@@ -261,31 +271,115 @@ class Essence:
261
271
 
262
272
  def override_timing_info(
263
273
  self,
264
- source_frame_rate=None,
274
+ frame_rate: FrameRate | None = None,
265
275
  ffoa_timecode: str | None = None,
266
- lfoa_timecode: str | None = None
276
+ lfoa_timecode: str | None = None,
277
+ file_start_timecode: str | None = None,
278
+ head_leader_length: int | None = None,
279
+ tail_leader_length: int | None = None
267
280
  ) -> None:
268
281
  """Override timing information for the essence.
269
282
 
270
- All parameters are optional. Only provided values will be set.
283
+ Timing parameters come in two mutually exclusive groups. When using either group,
284
+ ALL parameters in that group must be provided:
285
+ - Timecode-based: frame_rate, ffoa_timecode, lfoa_timecode, file_start_timecode (all four required together)
286
+ - Offset-based: frame_rate, head_leader_length, tail_leader_length (all three required together)
271
287
 
272
288
  Args:
273
- source_frame_rate: The source frame rate (e.g., FrameRate.TWENTY_FOUR).
289
+ frame_rate (FrameRate, optional): The source frame rate. Required with all timing groups.
274
290
  ffoa_timecode (str, optional): First frame of action timecode.
275
291
  lfoa_timecode (str, optional): Last frame of action timecode.
292
+ file_start_timecode (str, optional): File start timecode.
293
+ head_leader_length (int, optional): Head leader length in frames.
294
+ tail_leader_length (int, optional): Tail leader length in frames.
295
+
296
+ Raises:
297
+ ValueError: If both timecode-based and offset-based parameters are provided.
298
+ ValueError: If only some parameters from a group are provided.
276
299
 
277
300
  """
278
- # Initialize timing_info if it's None
279
- if self.payload["timing_info"] is None:
280
- self.payload["timing_info"] = {}
301
+ # Define parameter groups (excluding frame_rate from the "other params" check)
302
+ timecode_other_params = {
303
+ "ffoa_timecode": ffoa_timecode,
304
+ "lfoa_timecode": lfoa_timecode,
305
+ "file_start_timecode": file_start_timecode
306
+ }
307
+
308
+ offset_other_params = {
309
+ "head_leader_length": head_leader_length,
310
+ "tail_leader_length": tail_leader_length
311
+ }
281
312
 
282
- if source_frame_rate is not None:
283
- fr_value = source_frame_rate.value if hasattr(source_frame_rate, 'value') else source_frame_rate
284
- self.payload["timing_info"]["source_frame_rate"] = fr_value
313
+ timecode_other_set = [k for k, v in timecode_other_params.items() if v is not None]
314
+ offset_other_set = [k for k, v in offset_other_params.items() if v is not None]
315
+
316
+ # Check if parameters from both groups are provided
317
+ if timecode_other_set and offset_other_set:
318
+ raise ValueError(
319
+ "Timecode-based parameters (frame_rate, ffoa_timecode, lfoa_timecode, file_start_timecode) "
320
+ "and offset-based parameters (frame_rate, head_leader_length, tail_leader_length) are mutually exclusive. "
321
+ "Please provide only one type of timing parameter."
322
+ )
323
+
324
+ # Validate timecode group - if any timecode param is provided, all must be provided (including frame_rate)
325
+ if timecode_other_set:
326
+ full_timecode_params = {
327
+ "frame_rate": frame_rate,
328
+ "ffoa_timecode": ffoa_timecode,
329
+ "lfoa_timecode": lfoa_timecode,
330
+ "file_start_timecode": file_start_timecode
331
+ }
332
+ complete_timecode_set = [k for k, v in full_timecode_params.items() if v is not None]
333
+ if len(complete_timecode_set) != len(full_timecode_params):
334
+ missing = [k for k, v in full_timecode_params.items() if v is None]
335
+ raise ValueError(
336
+ f"When using timecode-based parameters, all must be provided. "
337
+ f"Missing: {', '.join(missing)}"
338
+ )
339
+
340
+ # Validate offset group - if any offset param is provided, all must be provided (including frame_rate)
341
+ if offset_other_set:
342
+ full_offset_params = {
343
+ "frame_rate": frame_rate,
344
+ "head_leader_length": head_leader_length,
345
+ "tail_leader_length": tail_leader_length
346
+ }
347
+ complete_offset_set = [k for k, v in full_offset_params.items() if v is not None]
348
+ if len(complete_offset_set) != len(full_offset_params):
349
+ missing = [k for k, v in full_offset_params.items() if v is None]
350
+ raise ValueError(
351
+ f"When using offset-based parameters, all must be provided. "
352
+ f"Missing: {', '.join(missing)}"
353
+ )
354
+
355
+ # Check if frame_rate is provided alone (without any other timing params)
356
+ if frame_rate is not None and not timecode_other_set and not offset_other_set:
357
+ raise ValueError(
358
+ "frame_rate cannot be used alone. It must be provided as part of either: "
359
+ "timecode-based parameters (frame_rate, ffoa_timecode, lfoa_timecode, file_start_timecode) or "
360
+ "offset-based parameters (frame_rate, head_leader_length, tail_leader_length)."
361
+ )
362
+
363
+ if frame_rate is not None:
364
+ fr_value = frame_rate.value if hasattr(frame_rate, 'value') else frame_rate
365
+ self.payload["definition"]["frame_rate"] = fr_value
366
+
367
+ # For timecode based settings
285
368
  if ffoa_timecode is not None:
286
- self.payload["timing_info"]["ffoa_timecode"] = ffoa_timecode
287
- if lfoa_timecode is not None:
288
- self.payload["timing_info"]["lfoa_timecode"] = lfoa_timecode
369
+ self.payload["definition"]["ffoa_timecode"] = ffoa_timecode
370
+ self.payload["definition"]["lfoa_timecode"] = lfoa_timecode
371
+ self.payload["definition"]["file_start_timecode"] = file_start_timecode
372
+
373
+ # For offset based settings
374
+ if head_leader_length is not None:
375
+ # Remove any set timecode settings just in case
376
+ self.payload["definition"].pop("ffoa_timecode", None)
377
+ self.payload["definition"].pop("lfoa_timecode", None)
378
+ self.payload["definition"].pop("file_start_timecode", None)
379
+
380
+ # Set the offest values
381
+ self.payload["definition"]["head_leader_length"] = head_leader_length
382
+ self.payload["definition"]["tail_leader_length"] = tail_leader_length
289
383
 
290
384
  def override_bext_time_reference(self, bext_time_reference: int) -> None:
291
385
  """Set BEXT time reference on all resources.
@@ -401,23 +495,27 @@ class Essence:
401
495
  )
402
496
 
403
497
  j = json.loads(ret.stdout)
404
- print(json.dumps(j, indent=2))
405
498
  if not j.get("sources"):
406
499
  raise ValueError("`coda inspect` was unable to retrieve the sources information.")
407
500
 
408
501
  timing_info = {
409
- "source_frame_rate": j.get("source_frame_rate"),
502
+ "frame_rate": j.get("source_frame_rate"),
410
503
  "ffoa_timecode": j.get("ffoa_timecode"),
411
- "lfoa_timecode": j.get("lfoa_timecode")
504
+ "lfoa_timecode": j.get("lfoa_timecode"),
505
+ "file_start_timecode": j.get("file_start_timecode")
412
506
  }
413
507
 
414
508
  for source in j.get("sources", []):
415
509
  source_type = source.get("type")
416
- if source_type in [SourceType.ADM, SourceType.IAB_MXF]:
417
- format = Format.ATMOS
418
510
  source_def = source.get("definition")
511
+
512
+ # Determine the format with explicit type handling
513
+ format_value: str | Format = source_def.get("format", "")
514
+ if not format_value and source_type in [SourceType.ADM, SourceType.IAB_MXF]:
515
+ format_value = Format.ATMOS
516
+
419
517
  essence = Essence(
420
- format=source_def.get("format") or format,
518
+ format=format_value,
421
519
  stem_type=source_def.get("type"),
422
520
  program=source_def.get("program", program),
423
521
  description=source_def.get("description"),
coda/sdk/exceptions.py ADDED
@@ -0,0 +1,49 @@
1
+ """Exception classes for Coda API errors."""
2
+
3
+ import requests
4
+
5
+
6
+ class CodaAPIError(Exception):
7
+ """Base exception for Coda API errors.
8
+
9
+ Attributes:
10
+ status_code: HTTP status code
11
+ response: The full requests.Response object
12
+ endpoint: The API endpoint that was called
13
+ """
14
+
15
+ def __init__(self, message: str, status_code: int, response: requests.Response, endpoint: str):
16
+ self.status_code = status_code
17
+ self.response = response
18
+ self.endpoint = endpoint
19
+ super().__init__(message)
20
+
21
+
22
+ class CodaAuthenticationError(CodaAPIError):
23
+ """401 Unauthorized - Invalid or expired API token."""
24
+ pass
25
+
26
+
27
+ class CodaForbiddenError(CodaAPIError):
28
+ """403 Forbidden - Insufficient permissions for this resource."""
29
+ pass
30
+
31
+
32
+ class CodaBadRequestError(CodaAPIError):
33
+ """400 Bad Request - Invalid request payload or parameters."""
34
+ pass
35
+
36
+
37
+ class CodaNotFoundError(CodaAPIError):
38
+ """404 Not Found - Resource does not exist."""
39
+ pass
40
+
41
+
42
+ class CodaClientError(CodaAPIError):
43
+ """4XX Client Error (other than 400, 401, 403, 404)."""
44
+ pass
45
+
46
+
47
+ class CodaServerError(CodaAPIError):
48
+ """5XX Server Error - Coda API server error."""
49
+ pass
coda/sdk/job.py CHANGED
@@ -10,6 +10,7 @@ from coda.sdk.enums import Format, FrameRate, Language, VenueType
10
10
  from .constants import DEFAULT_PROGRAM_ID
11
11
  from .essence import Essence
12
12
  from .utils import validate_group_id, make_request
13
+ from .exceptions import CodaServerError, CodaClientError
13
14
  from ..tc_tools import tc_to_time_seconds
14
15
 
15
16
  if TYPE_CHECKING:
@@ -73,25 +74,114 @@ class JobPayloadBuilder:
73
74
  return self
74
75
 
75
76
  def with_input_timing(
76
- self, frame_rate: FrameRate | None = None, ffoa: str | None = None, lfoa: str | None = None, start_time: str | None = None
77
+ self,
78
+ frame_rate: FrameRate | None = None,
79
+ ffoa: str | None = None,
80
+ lfoa: str | None = None,
81
+ start_time: str | None = None,
82
+ head_leader_length: int | None = None,
83
+ tail_leader_length: int | None = None
77
84
  ) -> "JobPayloadBuilder":
78
85
  """Set the input timing information for the source files.
79
86
 
87
+ Note: This will override the timing info for every defined essence.
88
+
89
+ Timing parameters come in two mutually exclusive groups. When using either group,
90
+ ALL parameters in that group must be provided:
91
+ - Timecode-based: frame_rate, ffoa, lfoa, start_time (all four required together)
92
+ - Offset-based: frame_rate, head_leader_length, tail_leader_length (all three required together)
93
+
80
94
  Args:
81
- frame_rate (FrameRate, optional): The frame rate enum. Defaults to None.
82
- ffoa (str, optional): The first frame of audio timecode. Defaults to None.
83
- lfoa (str, optional): The last frame of audio timecode. Defaults to None.
84
- start_time (str, optional): The start time in timecode format. Defaults to None.
95
+ frame_rate (FrameRate, optional): The frame rate enum. Required with all timing groups.
96
+ ffoa (str, optional): The first frame of action timecode. Defaults to None.
97
+ lfoa (str, optional): The last frame of action timecode. Defaults to None.
98
+ start_time (str, optional): The file start timecode. When provided with frame_rate,
99
+ also used to calculate bext_time_reference for resources. Defaults to None.
100
+ head_leader_length (int, optional): Head leader length in frames. Defaults to None.
101
+ tail_leader_length (int, optional): Tail leader length in frames. Defaults to None.
85
102
 
86
103
  Returns:
87
104
  JobPayloadBuilder: The builder instance for fluent chaining.
88
105
 
106
+ Raises:
107
+ ValueError: If both timecode-based and offset-based parameters are provided.
108
+ ValueError: If only some parameters from a group are provided.
109
+
89
110
  """
111
+ # Define parameter groups (excluding frame_rate from the "other params" check)
112
+ timecode_other_params = {
113
+ "ffoa": ffoa,
114
+ "lfoa": lfoa,
115
+ "start_time": start_time
116
+ }
117
+
118
+ offset_other_params = {
119
+ "head_leader_length": head_leader_length,
120
+ "tail_leader_length": tail_leader_length
121
+ }
122
+
123
+ timecode_other_set = [k for k, v in timecode_other_params.items() if v is not None]
124
+ offset_other_set = [k for k, v in offset_other_params.items() if v is not None]
125
+
126
+ # Check mutual exclusivity between timecode and offset groups
127
+ if timecode_other_set and offset_other_set:
128
+ raise ValueError(
129
+ "Timecode-based parameters (frame_rate, ffoa, lfoa, start_time) "
130
+ "and offset-based parameters (frame_rate, head_leader_length, tail_leader_length) are mutually exclusive. "
131
+ "Please provide only one type of timing parameter."
132
+ )
133
+
134
+ # Validate timecode group - if any timecode param is provided, all must be provided (including frame_rate)
135
+ if timecode_other_set:
136
+ full_timecode_params = {
137
+ "frame_rate": frame_rate,
138
+ "ffoa": ffoa,
139
+ "lfoa": lfoa,
140
+ "start_time": start_time
141
+ }
142
+ complete_timecode_set = [k for k, v in full_timecode_params.items() if v is not None]
143
+ if len(complete_timecode_set) != len(full_timecode_params):
144
+ missing = [k for k, v in full_timecode_params.items() if v is None]
145
+ raise ValueError(
146
+ f"When using timecode-based parameters, all must be provided. "
147
+ f"Missing: {', '.join(missing)}"
148
+ )
149
+
150
+ # Validate offset group - if any offset param is provided, all must be provided (including frame_rate)
151
+ if offset_other_set:
152
+ full_offset_params = {
153
+ "frame_rate": frame_rate,
154
+ "head_leader_length": head_leader_length,
155
+ "tail_leader_length": tail_leader_length
156
+ }
157
+ complete_offset_set = [k for k, v in full_offset_params.items() if v is not None]
158
+ if len(complete_offset_set) != len(full_offset_params):
159
+ missing = [k for k, v in full_offset_params.items() if v is None]
160
+ raise ValueError(
161
+ f"When using offset-based parameters, all must be provided. "
162
+ f"Missing: {', '.join(missing)}"
163
+ )
164
+
165
+ # Check if frame_rate is provided alone (without any other timing params)
166
+ if frame_rate is not None and not timecode_other_set and not offset_other_set:
167
+ raise ValueError(
168
+ "frame_rate cannot be used alone. It must be provided as part of either: "
169
+ "timecode-based parameters (frame_rate, ffoa, lfoa, start_time) or "
170
+ "offset-based parameters (frame_rate, head_leader_length, tail_leader_length)."
171
+ )
172
+
90
173
  self._time_options["frame_rate"] = frame_rate
91
174
  self._time_options["ffoa"] = ffoa
92
175
  self._time_options["lfoa"] = lfoa
93
176
  if start_time is not None:
94
- self._time_options["start_time"] = tc_to_time_seconds(start_time, frame_rate)
177
+ self._time_options["file_start_timecode"] = start_time
178
+ if frame_rate is not None:
179
+ self._time_options["start_time"] = tc_to_time_seconds(start_time, frame_rate)
180
+
181
+ if head_leader_length is not None:
182
+ self._time_options["head_leader_length"] = head_leader_length
183
+ self._time_options["tail_leader_length"] = tail_leader_length
184
+
95
185
  return self
96
186
 
97
187
  def with_essences(self, essences: List[Essence]) -> "JobPayloadBuilder":
@@ -357,27 +447,34 @@ class JobPayloadBuilder:
357
447
  if not self._venue:
358
448
  raise ValueError("Cannot build job payload: A venue must be set.")
359
449
 
360
- ffoa = None
361
- lfoa = None
362
- fr = None
363
-
364
- if self._time_options.get("frame_rate"):
365
- fr = self._time_options.get("frame_rate")
366
- if self._time_options.get("ffoa"):
367
- ffoa = self._time_options.get("ffoa")
368
- if self._time_options.get("lfoa"):
369
- lfoa = self._time_options.get("lfoa")
370
-
371
- for e in self._essences:
372
- current_timing = e.payload.get("timing_info") or {}
373
- new_timing = dict(current_timing)
374
- if not new_timing.get("ffoa_timecode"):
375
- new_timing["ffoa_timecode"] = ffoa
376
- if not new_timing.get("lfoa_timecode"):
377
- new_timing["lfoa_timecode"] = lfoa
378
- if not new_timing.get("source_frame_rate"):
379
- new_timing["source_frame_rate"] = fr
380
- e.payload["timing_info"] = new_timing
450
+ # Apply timing overrides to all essences BEFORE serialization
451
+ for essence in self._essences:
452
+ # Apply frame rate if set (can be independent of other params)
453
+ if self._time_options.get("frame_rate"):
454
+ frame_rate = self._time_options["frame_rate"]
455
+ essence.payload["definition"]["frame_rate"] = frame_rate.value if hasattr(frame_rate, 'value') else frame_rate
456
+
457
+ # Apply timecode-based params (mutually exclusive with offset)
458
+ if self._time_options.get("ffoa"):
459
+ # Remove offset params if they exist
460
+ essence.payload["definition"].pop("head_leader_length", None)
461
+ essence.payload["definition"].pop("tail_leader_length", None)
462
+
463
+ # Set timecode params
464
+ essence.payload["definition"]["ffoa_timecode"] = self._time_options["ffoa"]
465
+ essence.payload["definition"]["lfoa_timecode"] = self._time_options["lfoa"]
466
+ essence.payload["definition"]["file_start_timecode"] = self._time_options["file_start_timecode"]
467
+
468
+ # Apply offset-based params (mutually exclusive with timecode)
469
+ elif self._time_options.get("head_leader_length") is not None:
470
+ # Remove timecode params if they exist
471
+ essence.payload["definition"].pop("ffoa_timecode", None)
472
+ essence.payload["definition"].pop("lfoa_timecode", None)
473
+ essence.payload["definition"].pop("file_start_timecode", None)
474
+
475
+ # Set offset params
476
+ essence.payload["definition"]["head_leader_length"] = self._time_options["head_leader_length"]
477
+ essence.payload["definition"]["tail_leader_length"] = self._time_options["tail_leader_length"]
381
478
 
382
479
  sources = [e.dict() for e in self._essences]
383
480
 
@@ -397,9 +494,6 @@ class JobPayloadBuilder:
397
494
  },
398
495
  "venue": self._venue,
399
496
  "sources": sources,
400
- "source_frame_rate": fr,
401
- "ffoa_timecode": ffoa,
402
- "lfoa_timecode": lfoa,
403
497
  }
404
498
 
405
499
  if self._edits:
@@ -456,6 +550,9 @@ class Job:
456
550
  Returns:
457
551
  requests.Response: The validation response object.
458
552
 
553
+ Raises:
554
+ CodaAPIError: If validation fails (HTTP 4XX or 5XX response).
555
+
459
556
  """
460
557
  endpoint = f"/interface/v2/groups/{self.group_id}/jobs/validate?skip_cloud_validation={skip_cloud_validation}"
461
558
  return make_request(requests.post, endpoint, self.payload)
@@ -466,15 +563,13 @@ class Job:
466
563
  Returns:
467
564
  int | None: The job ID if successful, otherwise None.
468
565
 
566
+ Raises:
567
+ CodaAPIError: If validation or job execution fails (HTTP 4XX or 5XX response).
568
+
469
569
  """
470
570
  print("Validating job payload.", file=sys.stderr)
471
571
  validation_result = self.validate()
472
572
 
473
- if validation_result.status_code != 200:
474
- print("Job validation failed. Cannot run job.", file=sys.stderr)
475
- print(validation_result.json(), file=sys.stderr)
476
- return None
477
-
478
573
  print("Launching job.", file=sys.stderr)
479
574
  endpoint = f"/interface/v2/groups/{self.group_id}/jobs"
480
575
  response = make_request(requests.post, endpoint, self.payload)
@@ -497,20 +592,14 @@ class Job:
497
592
  dict: The coda edge payload.
498
593
 
499
594
  Raises:
500
- RuntimeError: If job validation fails or edge payload retrieval fails.
595
+ CodaAPIError: If job validation or edge payload retrieval fails.
501
596
 
502
597
  """
503
598
  validation_result = self.validate(skip_cloud_validation=skip_cloud_validation)
504
599
 
505
- if validation_result.status_code != 200:
506
- raise RuntimeError(f"Edge job validation failed. \nStatus: {validation_result.status_code}\n Resp: {validation_result.json()}")
507
-
508
600
  endpoint = f"/interface/v2/groups/{self.group_id}/edge?skip_cloud_validation={skip_cloud_validation}"
509
601
  response = make_request(requests.post, endpoint, self.payload)
510
602
 
511
- if response.status_code != 200:
512
- raise RuntimeError(f"Edge payload retrieval failed with status code: {response.json()}")
513
-
514
603
  try:
515
604
  edge_payload = response.json()
516
605
  except Exception as err:
@@ -531,6 +620,9 @@ class Job:
531
620
  Returns:
532
621
  requests.Response: The raw payload validation response object.
533
622
 
623
+ Raises:
624
+ CodaAPIError: If validation fails (HTTP 4XX or 5XX response).
625
+
534
626
  """
535
627
  group_id = validate_group_id()
536
628
  endpoint = f"/interface/v2/groups/{group_id}/jobs/validate"
@@ -548,14 +640,13 @@ class Job:
548
640
  Returns:
549
641
  int | None: The job ID if successful, otherwise None.
550
642
 
643
+ Raises:
644
+ CodaAPIError: If validation or job execution fails (HTTP 4XX or 5XX response).
645
+
551
646
  """
552
647
  group_id = validate_group_id()
553
648
 
554
649
  validation_result = Job.validate_raw_payload(json_payload)
555
- if validation_result.status_code != 200:
556
- print("Raw payload validation failed. Cannot run job.", file=sys.stderr)
557
- print(validation_result.json(), file=sys.stderr)
558
- return None
559
650
 
560
651
  endpoint = f"/interface/v2/groups/{group_id}/jobs"
561
652
  response = make_request(requests.post, endpoint, json_payload)
@@ -572,7 +663,8 @@ class Job:
572
663
  """Get the status of a job.
573
664
 
574
665
  This method polls the API for the job's status and will retry up to 3 times
575
- if an error is encountered during the request.
666
+ if a server or client error (5XX or certain 4XX responses) is encountered.
667
+ Returns None if all retries are exhausted.
576
668
 
577
669
  Args:
578
670
  job_id (int): The ID of the job.
@@ -580,24 +672,30 @@ class Job:
580
672
  Returns:
581
673
  dict | None: The job status and progress if successful, otherwise None.
582
674
 
675
+ Raises:
676
+ CodaAuthenticationError: If API returns 401 (unauthorized).
677
+ CodaForbiddenError: If API returns 403 (insufficient permissions).
678
+ CodaNotFoundError: If API returns 404 (job not found).
679
+
583
680
  """
584
681
  group_id = validate_group_id()
585
- ret = make_request(
586
- requests.get, f"/interface/v2/groups/{group_id}/jobs/{job_id}"
587
- )
588
- j = ret.json()
589
682
  error_count = 0
590
- while "error" in j and error_count < 3:
591
- print("error in get_status: ", ret.status_code, j["error"], file=sys.stderr)
592
- time.sleep(1)
593
- ret = make_request(
594
- requests.get, f"/interface/v2/groups/{group_id}/jobs/{job_id}"
595
- )
596
- j = ret.json()
597
- error_count += 1
598
- if "error" in j:
599
- return None
600
- return {"status": j["status"], "progress": j["progress"]}
683
+ max_retries = 3
684
+
685
+ while error_count < max_retries:
686
+ try:
687
+ ret = make_request(
688
+ requests.get, f"/interface/v2/groups/{group_id}/jobs/{job_id}"
689
+ )
690
+ j = ret.json()
691
+ return {"status": j["status"], "progress": j["progress"]}
692
+ except (CodaServerError, CodaClientError) as e:
693
+ error_count += 1
694
+ if error_count >= max_retries:
695
+ print(f"error in get_status (attempt {error_count}): {e}", file=sys.stderr)
696
+ return None
697
+ print(f"error in get_status (attempt {error_count}): {e}", file=sys.stderr)
698
+ time.sleep(1)
601
699
 
602
700
  @staticmethod
603
701
  def get_report(job_id: int) -> dict:
@@ -609,6 +707,9 @@ class Job:
609
707
  Returns:
610
708
  dict: The job report JSON.
611
709
 
710
+ Raises:
711
+ CodaAPIError: If report retrieval fails (HTTP 4XX or 5XX response).
712
+
612
713
  """
613
714
  ret = make_request(requests.get, f"/interface/v2/report/{job_id}/raw")
614
715
  return ret.json()
@@ -624,6 +725,9 @@ class Job:
624
725
  Returns:
625
726
  list: List of jobs within the date range.
626
727
 
728
+ Raises:
729
+ CodaAPIError: If query fails (HTTP 4XX or 5XX response).
730
+
627
731
  """
628
732
  ret = make_request(requests.get, f"/interface/v1/jobs?sort=asc&start_date={start_date}&end_date={end_date}")
629
733
  return ret.json()
coda/sdk/preset.py CHANGED
@@ -137,10 +137,7 @@ class Preset:
137
137
 
138
138
  route = Preset.routes[preset_type].replace(":group_id", str(group_id))
139
139
  ret = make_request(requests.get, f"/interface/v2/{route}")
140
- j = ret.json()
141
- if "error" in j:
142
- raise ValueError(f"Unable to find preset '{preset_type}': {j}")
143
- return j
140
+ return ret.json()
144
141
 
145
142
  @staticmethod
146
143
  def get_group_id_by_name(group_name: str) -> str:
coda/sdk/utils.py CHANGED
@@ -3,7 +3,7 @@ import requests
3
3
  import re
4
4
  import urllib3
5
5
 
6
- from typing import TYPE_CHECKING, List, Dict, Any, Callable
6
+ from typing import TYPE_CHECKING, List, Dict, Any, Callable, cast
7
7
 
8
8
  from .constants import (
9
9
  ENV_CODA_API_GROUP_ID,
@@ -13,6 +13,15 @@ from .constants import (
13
13
  DEFAULT_API_URL,
14
14
  INSECURE_SKIP_VERIFY_VALUES,
15
15
  )
16
+ from .exceptions import (
17
+ CodaAPIError,
18
+ CodaAuthenticationError,
19
+ CodaForbiddenError,
20
+ CodaBadRequestError,
21
+ CodaNotFoundError,
22
+ CodaClientError,
23
+ CodaServerError,
24
+ )
16
25
 
17
26
  if TYPE_CHECKING:
18
27
  from ..tc_tools import (
@@ -81,6 +90,7 @@ def user_info() -> str:
81
90
  return ret.json()
82
91
 
83
92
 
93
+
84
94
  def validate_group_id() -> str:
85
95
  """Get the Coda Group ID from environment variables.
86
96
 
@@ -113,6 +123,8 @@ def make_request(
113
123
  headers, and executes the request using the provided function (e.g.,
114
124
  requests.get, requests.post).
115
125
 
126
+ HTTP errors are automatically detected and converted to informative exceptions.
127
+
116
128
  Args:
117
129
  func (Callable[..., requests.Response]): The requests function to call
118
130
  (e.g., requests.get, requests.post, requests.put).
@@ -122,9 +134,17 @@ def make_request(
122
134
 
123
135
  Raises:
124
136
  ValueError: If the 'CODA_API_TOKEN' environment variable is not set.
137
+ CodaAuthenticationError: If the API returns 401 Unauthorized.
138
+ CodaForbiddenError: If the API returns 403 Forbidden.
139
+ CodaBadRequestError: If the API returns 400 Bad Request.
140
+ CodaNotFoundError: If the API returns 404 Not Found.
141
+ CodaClientError: If the API returns other 4XX errors.
142
+ CodaServerError: If the API returns 5XX server errors.
143
+ CodaAPIError: If the API returns an unexpected status code (e.g., 3XX).
125
144
 
126
145
  Returns:
127
146
  requests.Response: The Response object from the `requests` library.
147
+ Only returned for successful 2XX status codes.
128
148
 
129
149
  """
130
150
  url = os.getenv(ENV_CODA_API_URL, DEFAULT_API_URL)
@@ -136,7 +156,12 @@ def make_request(
136
156
  urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
137
157
  verify = False
138
158
  auth = {"Authorization": f"Bearer {token}"}
139
- return func(url, json=payload, headers=auth, verify=verify)
159
+ response = func(url, json=payload, headers=auth, verify=verify)
160
+
161
+ # Check HTTP status and raise appropriate exception for errors
162
+ _check_response_status(response, route)
163
+
164
+ return response
140
165
  raise ValueError("Error: CODA_API_TOKEN is not set.")
141
166
 
142
167
 
@@ -280,3 +305,133 @@ def is_key_value_comma_string(s: str) -> bool:
280
305
  pattern = r"^([A-Z0-9_]+=[a-zA-Z0-9_-]+)(,[A-Z0-9_]+=[a-zA-Z0-9_-]+)*$"
281
306
 
282
307
  return re.fullmatch(pattern, s) is not None
308
+
309
+ def _extract_error_detail(response: requests.Response) -> str:
310
+ """Extract error detail from response body.
311
+
312
+ Tries multiple strategies to get meaningful error info:
313
+ 1. Check for 'error' key in JSON
314
+ 2. Check for 'message' key in JSON
315
+ 3. Check for 'errors' array in JSON
316
+ 4. Fall back to response.text (truncated)
317
+
318
+ Args:
319
+ response: The requests Response object
320
+
321
+ Returns:
322
+ str: Error detail string, or empty string if none found
323
+ """
324
+ try:
325
+ response_json = response.json()
326
+ if isinstance(response_json, dict):
327
+ # Check common error keys
328
+ if "error" in response_json:
329
+ error = response_json["error"]
330
+ # Handle nested error objects
331
+ if isinstance(error, dict) and "message" in error:
332
+ return error["message"]
333
+ return str(error)
334
+ elif "message" in response_json:
335
+ return response_json["message"]
336
+ elif "errors" in response_json:
337
+ errors = response_json["errors"]
338
+ if isinstance(errors, list) and errors:
339
+ return "; ".join(str(e) for e in errors[:3]) # First 3 errors
340
+ return str(errors)
341
+ except Exception:
342
+ pass
343
+
344
+ # Fall back to text response, truncated
345
+ if response.text:
346
+ text = response.text.strip()
347
+ if len(text) > 200:
348
+ return text[:200] + "..."
349
+ return text
350
+
351
+ return ""
352
+
353
+
354
+ def _check_response_status(response: requests.Response, endpoint: str) -> None:
355
+ """Check response status code and raise exception for HTTP errors.
356
+
357
+ Raises specific exception types based on the HTTP status code to enable
358
+ targeted error handling. All exceptions include the status code, endpoint,
359
+ and extracted error details from the response body.
360
+
361
+ Args:
362
+ response: The requests Response object to check
363
+ endpoint: The API endpoint being called (for error messages)
364
+
365
+ Raises:
366
+ CodaAuthenticationError: For 401 status
367
+ CodaForbiddenError: For 403 status
368
+ CodaBadRequestError: For 400 status
369
+ CodaNotFoundError: For 404 status
370
+ CodaClientError: For other 4XX statuses
371
+ CodaServerError: For 5XX statuses
372
+ """
373
+ status_code: int = cast(int, response.status_code)
374
+
375
+ # Success - no exception needed
376
+ if 200 <= status_code < 300:
377
+ return
378
+
379
+ error_detail = _extract_error_detail(response)
380
+ base_message = f"HTTP {status_code} error for endpoint '{endpoint}'"
381
+ if error_detail:
382
+ full_message = f"{base_message}: {error_detail}"
383
+ else:
384
+ full_message = base_message
385
+
386
+ # Raise specific exception based on status code
387
+ if status_code == 401:
388
+ raise CodaAuthenticationError(
389
+ full_message,
390
+ status_code=status_code,
391
+ response=response,
392
+ endpoint=endpoint
393
+ )
394
+ elif status_code == 403:
395
+ raise CodaForbiddenError(
396
+ full_message,
397
+ status_code=status_code,
398
+ response=response,
399
+ endpoint=endpoint
400
+ )
401
+ elif status_code == 400:
402
+ raise CodaBadRequestError(
403
+ full_message,
404
+ status_code=status_code,
405
+ response=response,
406
+ endpoint=endpoint
407
+ )
408
+ elif status_code == 404:
409
+ raise CodaNotFoundError(
410
+ full_message,
411
+ status_code=status_code,
412
+ response=response,
413
+ endpoint=endpoint
414
+ )
415
+ elif 400 <= status_code < 500:
416
+ raise CodaClientError(
417
+ full_message,
418
+ status_code=status_code,
419
+ response=response,
420
+ endpoint=endpoint
421
+ )
422
+ elif 500 <= status_code < 600:
423
+ raise CodaServerError(
424
+ full_message,
425
+ status_code=status_code,
426
+ response=response,
427
+ endpoint=endpoint
428
+ )
429
+ else:
430
+ # Unexpected status code (3XX, 1XX, etc.)
431
+ raise CodaAPIError(
432
+ full_message,
433
+ status_code=status_code,
434
+ response=response,
435
+ endpoint=endpoint
436
+ )
437
+
coda/sdk/workflow.py CHANGED
@@ -180,7 +180,7 @@ class WorkflowDefinitionBuilder:
180
180
 
181
181
  if isinstance(loudness_preset, dict) and "tolerances" not in loudness_preset:
182
182
  loudness_preset["tolerances"] = _DEFAULT_LOUDNESS_TOLERANCES.copy()
183
- process_block_config = {
183
+ process_block_config: Dict[str, Any] = {
184
184
  "name": name,
185
185
  "input_filter": input_filter,
186
186
  "output_settings": {
@@ -1151,7 +1151,7 @@ class WorkflowDefinitionBuilder:
1151
1151
  if s3_auth:
1152
1152
  dest_def["auth"] = s3_auth
1153
1153
  if options:
1154
- dest_def["url_options"] = options
1154
+ dest_def["opts"] = options
1155
1155
 
1156
1156
  if io_location_id is not None and s3_url is None:
1157
1157
  dest_type = "io_location"
@@ -1214,7 +1214,7 @@ class WorkflowDefinitionBuilder:
1214
1214
  WorkflowDefinition: A new Workflow instance containing the built definition.
1215
1215
 
1216
1216
  """
1217
- definition = {
1217
+ definition: Dict[str, Any] = {
1218
1218
  "name": self._name,
1219
1219
  "process_blocks": copy.deepcopy(self._process_blocks),
1220
1220
  "packages": copy.deepcopy(self._packages),
coda/sdk.py ADDED
@@ -0,0 +1 @@
1
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sprocket-systems.coda.sdk
3
- Version: 2.0.8
3
+ Version: 2.0.11
4
4
  Summary: The Coda SDK provides a Python interface to define Coda workflows, create jobs and run them.
5
5
  Keywords: python,coda,sdk
6
6
  Author-Email: Sprocket Systems <support@sprocket.systems>
@@ -0,0 +1,17 @@
1
+ coda/__init__.py,sha256=ko0iicXLw6_xgFxn-3Y8m5XFFkU6JKTSCNM1m9m6aic,230
2
+ coda/sdk.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
+ coda/sdk/__init__.py,sha256=1qAK6kMsLo7kt14vI2XIZGv-lX8JcceVuARuevNaz6o,1055
4
+ coda/sdk/constants.py,sha256=DW-goGI4vTlt8KeR4z0sf89Duov4TgZdsjv1VJDuAQI,596
5
+ coda/sdk/enums.py,sha256=1l4pK8pNofV56WVAiQXitlY6m9iTfEv9I159GWkTZKE,6582
6
+ coda/sdk/essence.py,sha256=8E8oA6QctCS9wzsxyf0UKtx_XpZc44rfpGxXZTvbrw0,26161
7
+ coda/sdk/exceptions.py,sha256=H_FAbKlTj_i_sRA66VHFp33Qu0ReFcup8_WMrJG9huE,1208
8
+ coda/sdk/job.py,sha256=CaSrxK7QvkThx_ZVWslLC363xbgoWKTTIjIfDPOk1h4,27034
9
+ coda/sdk/preset.py,sha256=WG2T51HIffcYS_TUwqWGNmRv-3_2etM8YBoka2-ztbM,8819
10
+ coda/sdk/utils.py,sha256=tGM3mTYfkafZ925G_Z6jIUsz91DyevwVytKB8xH7hIM,15651
11
+ coda/sdk/workflow.py,sha256=ipAiiZ-NV7yFrbyBo2xni-57fzbkl5J0lkH8XwxZkd4,63196
12
+ coda/tc_tools.py,sha256=hEtVE6xtPqg93WfJ-lQdRQroPdQTkH5HkMLWCIkwIlo,22010
13
+ sprocket_systems_coda_sdk-2.0.11.dist-info/METADATA,sha256=xH0LcwvlFfdVJxYyhf2P7jzB0JomYYFh9LY6UQbOnOg,1107
14
+ sprocket_systems_coda_sdk-2.0.11.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
15
+ sprocket_systems_coda_sdk-2.0.11.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
16
+ sprocket_systems_coda_sdk-2.0.11.dist-info/licenses/LICENSE,sha256=wrFjizFlraIAPW8JIteGftNH2laAZBBRhdEnPVUsKTM,10198
17
+ sprocket_systems_coda_sdk-2.0.11.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- coda/__init__.py,sha256=vE1LP8NOlIPcHtFGopI2SkUBTkwyY8XPWQmGfz4PEzo,229
2
- coda/sdk/__init__.py,sha256=wOCrh1piLbgLVOMhKuLFYA62n60IN89eXGHpFKwFAE4,691
3
- coda/sdk/constants.py,sha256=DW-goGI4vTlt8KeR4z0sf89Duov4TgZdsjv1VJDuAQI,596
4
- coda/sdk/enums.py,sha256=1l4pK8pNofV56WVAiQXitlY6m9iTfEv9I159GWkTZKE,6582
5
- coda/sdk/essence.py,sha256=vSx2lp1I0yJvFNMcAWqF-I3BV9q_bRnZbtSEf_QArHg,20764
6
- coda/sdk/job.py,sha256=WArS06ufJUZLDdx6wJHgwk2olYjtX3w_sJqPF_XEu6A,21538
7
- coda/sdk/preset.py,sha256=OtaRVJU6luBBD_6Ehd8ZkcjXL_YVoXfziI14E-GS4Xw,8934
8
- coda/sdk/utils.py,sha256=uC9hHJtMkGu3pLvJOwq9kIdf6r7KxMRAbzaRyf95EdQ,10565
9
- coda/sdk/workflow.py,sha256=QpGvjkNuFKh5XyawC7HXyx-nmC4EKbdRc-6eIjaQ79Q,63171
10
- coda/tc_tools.py,sha256=hEtVE6xtPqg93WfJ-lQdRQroPdQTkH5HkMLWCIkwIlo,22010
11
- sprocket_systems_coda_sdk-2.0.8.dist-info/METADATA,sha256=AoA1LH1gdh3NEvIAWqWRYcWObwRLwVCXjwaHpR_LRvw,1106
12
- sprocket_systems_coda_sdk-2.0.8.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
13
- sprocket_systems_coda_sdk-2.0.8.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
14
- sprocket_systems_coda_sdk-2.0.8.dist-info/licenses/LICENSE,sha256=wrFjizFlraIAPW8JIteGftNH2laAZBBRhdEnPVUsKTM,10198
15
- sprocket_systems_coda_sdk-2.0.8.dist-info/RECORD,,