airbyte-internal-ops 0.2.4__py3-none-any.whl → 0.3.1__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.
@@ -14,37 +14,62 @@ import requests
14
14
  from airbyte import constants
15
15
  from airbyte.exceptions import PyAirbyteInputError
16
16
 
17
+ from airbyte_ops_mcp import constants as ops_constants
18
+
17
19
  # Internal enums for scoped configuration API "magic strings"
18
20
  # These values caused issues during development and are now centralized here
19
21
 
20
22
 
21
23
  class _ScopedConfigKey(str, Enum):
22
- """Configuration keys used in scoped configuration API."""
24
+ """Configuration keys used in scoped configuration API.
25
+
26
+ Valid values from airbyte-platform-internal:
27
+ oss/airbyte-data/src/main/kotlin/io/airbyte/data/services/shared/ScopedConfigurationKey.kt
28
+ """
23
29
 
24
30
  CONNECTOR_VERSION = "connector_version"
31
+ NETWORK_SECURITY_TOKEN = "network_security_token"
32
+ PRODUCT_LIMITS = "product_limits"
25
33
 
26
34
 
27
35
  class _ResourceType(str, Enum):
28
- """Resource types for scoped configuration."""
36
+ """Resource types for scoped configuration.
37
+
38
+ Valid values from airbyte-platform-internal:
39
+ oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigResourceType.java
40
+ """
29
41
 
30
42
  ACTOR_DEFINITION = "actor_definition"
43
+ USER = "user"
31
44
  WORKSPACE = "workspace"
32
45
  ORGANIZATION = "organization"
46
+ CONNECTION = "connection"
47
+ SOURCE = "source"
48
+ DESTINATION = "destination"
33
49
 
34
50
 
35
51
  class _ScopeType(str, Enum):
36
- """Scope types for scoped configuration."""
52
+ """Scope types for scoped configuration.
53
+
54
+ Valid values from airbyte-platform-internal:
55
+ oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigScopeType.java
56
+ """
37
57
 
38
- ACTOR = "actor"
39
- WORKSPACE = "workspace"
40
58
  ORGANIZATION = "organization"
59
+ WORKSPACE = "workspace"
60
+ ACTOR = "actor"
41
61
 
42
62
 
43
63
  class _OriginType(str, Enum):
44
- """Origin types for scoped configuration."""
64
+ """Origin types for scoped configuration.
65
+
66
+ Valid values from airbyte-platform-internal:
67
+ oss/airbyte-config/config-models/src/generated/java/io/airbyte/config/ConfigOriginType.java
68
+ """
45
69
 
46
70
  USER = "user"
47
- SYSTEM = "system"
71
+ BREAKING_CHANGE = "breaking_change"
72
+ CONNECTOR_ROLLOUT = "connector_rollout"
48
73
 
49
74
 
50
75
  def _get_access_token(
@@ -132,7 +157,7 @@ def get_user_id_by_email(
132
157
  json={},
133
158
  headers={
134
159
  "Authorization": f"Bearer {access_token}",
135
- "User-Agent": "PyAirbyte Client",
160
+ "User-Agent": ops_constants.USER_AGENT,
136
161
  "Content-Type": "application/json",
137
162
  },
138
163
  timeout=30,
@@ -204,7 +229,7 @@ def resolve_connector_version_id(
204
229
  json=payload,
205
230
  headers={
206
231
  "Authorization": f"Bearer {access_token}",
207
- "User-Agent": "PyAirbyte Client",
232
+ "User-Agent": ops_constants.USER_AGENT,
208
233
  "Content-Type": "application/json",
209
234
  },
210
235
  timeout=30,
@@ -238,6 +263,130 @@ def resolve_connector_version_id(
238
263
  return version_id
239
264
 
240
265
 
266
+ def _get_scoped_configuration_context(
267
+ actor_definition_id: str,
268
+ scope_type: _ScopeType,
269
+ scope_id: str,
270
+ api_root: str,
271
+ access_token: str,
272
+ ) -> dict[str, Any] | None:
273
+ """Get the active scoped configuration for a single scope level.
274
+
275
+ This is the canonical way to query /scoped_configuration/get_context.
276
+ Used by both the unset path and the multi-scope checking in the set path.
277
+
278
+ Args:
279
+ actor_definition_id: The actor definition ID for the connector
280
+ scope_type: The scope type enum (ACTOR, WORKSPACE, or ORGANIZATION)
281
+ scope_id: The ID for the scope (connector_id, workspace_id, or organization_id)
282
+ api_root: The API root URL
283
+ access_token: Pre-authenticated access token
284
+
285
+ Returns:
286
+ The active configuration dict if an override exists at this scope, or None.
287
+
288
+ Raises:
289
+ PyAirbyteInputError: If the API request fails
290
+ """
291
+ endpoint = f"{api_root}/scoped_configuration/get_context"
292
+ context_payload = {
293
+ "config_key": _ScopedConfigKey.CONNECTOR_VERSION.value,
294
+ "resource_type": _ResourceType.ACTOR_DEFINITION.value,
295
+ "resource_id": actor_definition_id,
296
+ "scope_type": scope_type.value,
297
+ "scope_id": scope_id,
298
+ }
299
+
300
+ response = requests.post(
301
+ endpoint,
302
+ json=context_payload,
303
+ headers={
304
+ "Authorization": f"Bearer {access_token}",
305
+ "User-Agent": ops_constants.USER_AGENT,
306
+ "Content-Type": "application/json",
307
+ },
308
+ timeout=30,
309
+ )
310
+
311
+ if response.status_code != 200:
312
+ raise PyAirbyteInputError(
313
+ message=f"Failed to get scoped configuration context for {scope_type.value} scope: "
314
+ f"{response.status_code} {response.text}",
315
+ context={
316
+ "endpoint": endpoint,
317
+ "payload": context_payload,
318
+ "scope_type": scope_type.value,
319
+ "status_code": response.status_code,
320
+ "response": response.text,
321
+ },
322
+ )
323
+
324
+ context_data = response.json()
325
+ return context_data.get("activeConfiguration")
326
+
327
+
328
+ def get_all_scoped_configuration_contexts(
329
+ connector_id: str,
330
+ actor_definition_id: str,
331
+ workspace_id: str,
332
+ organization_id: str,
333
+ api_root: str,
334
+ client_id: str | None = None,
335
+ client_secret: str | None = None,
336
+ bearer_token: str | None = None,
337
+ ) -> dict[str, dict[str, Any]]:
338
+ """Get version override configurations at all scope levels (actor, workspace, organization).
339
+
340
+ This ALWAYS checks all three scope levels to provide a complete picture of any version
341
+ overrides that may affect the connector. All scope IDs are required to ensure comprehensive
342
+ checking - this function will not silently skip any scope.
343
+
344
+ Args:
345
+ connector_id: The ID of the deployed connector (source or destination)
346
+ actor_definition_id: The actor definition ID for the connector
347
+ workspace_id: The workspace ID (required - must always check workspace scope)
348
+ organization_id: The organization ID (required - must always check org scope)
349
+ api_root: The API root URL
350
+ client_id: The Airbyte Cloud client ID (required if no bearer_token)
351
+ client_secret: The Airbyte Cloud client secret (required if no bearer_token)
352
+ bearer_token: Pre-existing bearer token (takes precedence over client credentials)
353
+
354
+ Returns:
355
+ Dictionary containing only the scopes that have active configurations.
356
+ Empty dict if no overrides exist (falsy). Keys are 'actor', 'workspace',
357
+ 'organization' - only present if an override exists at that scope.
358
+
359
+ Raises:
360
+ PyAirbyteInputError: If any API request fails
361
+ """
362
+ access_token = _get_access_token(client_id, client_secret, bearer_token)
363
+
364
+ # Start with empty dict - only add entries for scopes that have active configs
365
+ # This ensures the result is falsy if nothing is set
366
+ results: dict[str, dict[str, Any]] = {}
367
+
368
+ # Always check all three scopes - no optional skipping
369
+ # Using enum values consistently to avoid magic strings
370
+ scopes_to_check: list[tuple[_ScopeType, str]] = [
371
+ (_ScopeType.ACTOR, connector_id),
372
+ (_ScopeType.WORKSPACE, workspace_id),
373
+ (_ScopeType.ORGANIZATION, organization_id),
374
+ ]
375
+
376
+ for scope_type_enum, scope_id in scopes_to_check:
377
+ active_config = _get_scoped_configuration_context(
378
+ actor_definition_id=actor_definition_id,
379
+ scope_type=scope_type_enum,
380
+ scope_id=scope_id,
381
+ api_root=api_root,
382
+ access_token=access_token,
383
+ )
384
+ if active_config:
385
+ results[scope_type_enum.value] = active_config
386
+
387
+ return results
388
+
389
+
241
390
  def get_connector_version(
242
391
  connector_id: str,
243
392
  connector_type: Literal["source", "destination"],
@@ -245,9 +394,14 @@ def get_connector_version(
245
394
  client_id: str | None = None,
246
395
  client_secret: str | None = None,
247
396
  bearer_token: str | None = None,
397
+ workspace_id: str | None = None,
248
398
  ) -> dict[str, Any]:
249
399
  """Get version information for a deployed connector.
250
400
 
401
+ This function retrieves the current version and override status. If workspace_id is provided,
402
+ it also fetches detailed scoped configuration context at all scope levels (actor, workspace,
403
+ organization) to provide a complete picture of any version pins.
404
+
251
405
  Args:
252
406
  connector_id: The ID of the deployed connector (source or destination)
253
407
  connector_type: Either "source" or "destination"
@@ -255,11 +409,14 @@ def get_connector_version(
255
409
  client_id: The Airbyte Cloud client ID (required if no bearer_token)
256
410
  client_secret: The Airbyte Cloud client secret (required if no bearer_token)
257
411
  bearer_token: Pre-existing bearer token (takes precedence over client credentials)
412
+ workspace_id: Optional workspace ID to enable detailed scope checking
258
413
 
259
414
  Returns:
260
415
  Dictionary containing:
261
416
  - dockerImageTag: The current version string
262
417
  - isVersionOverrideApplied: Boolean indicating if override is active
418
+ - scopedConfigs: (if workspace_id provided) Dict with 'actor', 'workspace', 'organization'
419
+ keys containing the active configuration at each scope level, or None
263
420
 
264
421
  Raises:
265
422
  PyAirbyteInputError: If the API request fails
@@ -271,16 +428,18 @@ def get_connector_version(
271
428
  if connector_type == "source":
272
429
  endpoint = f"{api_root}/actor_definition_versions/get_for_source"
273
430
  payload = {"sourceId": connector_id}
431
+ definition_id_key = "sourceDefinitionId"
274
432
  else:
275
433
  endpoint = f"{api_root}/actor_definition_versions/get_for_destination"
276
434
  payload = {"destinationId": connector_id}
435
+ definition_id_key = "destinationDefinitionId"
277
436
 
278
437
  response = requests.post(
279
438
  endpoint,
280
439
  json=payload,
281
440
  headers={
282
441
  "Authorization": f"Bearer {access_token}",
283
- "User-Agent": "PyAirbyte Client",
442
+ "User-Agent": ops_constants.USER_AGENT,
284
443
  "Content-Type": "application/json",
285
444
  },
286
445
  timeout=30,
@@ -302,14 +461,65 @@ def get_connector_version(
302
461
 
303
462
  data = response.json()
304
463
 
305
- # Defensively check for both possible field names
306
- return {
464
+ result: dict[str, Any] = {
307
465
  "dockerImageTag": data.get("dockerImageTag", "unknown"),
308
466
  "isVersionOverrideApplied": data.get(
309
467
  "isVersionOverrideApplied", data.get("isOverrideApplied", False)
310
468
  ),
311
469
  }
312
470
 
471
+ # If workspace_id is provided, also get detailed scoped configuration context
472
+ if workspace_id:
473
+ # Get actor_definition_id from the connector info
474
+ get_endpoint = f"{api_root}/{connector_type}s/get"
475
+ get_payload: dict[str, str] = {f"{connector_type}Id": connector_id}
476
+
477
+ get_response = requests.post(
478
+ get_endpoint,
479
+ json=get_payload,
480
+ headers={
481
+ "Authorization": f"Bearer {access_token}",
482
+ "User-Agent": ops_constants.USER_AGENT,
483
+ "Content-Type": "application/json",
484
+ },
485
+ timeout=30,
486
+ )
487
+
488
+ if get_response.status_code == 200:
489
+ connector_info = get_response.json()
490
+ actor_definition_id = connector_info.get(definition_id_key)
491
+
492
+ if actor_definition_id:
493
+ # Get organization_id from workspace
494
+ workspace_endpoint = f"{api_root}/workspaces/get"
495
+ workspace_response = requests.post(
496
+ workspace_endpoint,
497
+ json={"workspaceId": workspace_id},
498
+ headers={
499
+ "Authorization": f"Bearer {access_token}",
500
+ "User-Agent": ops_constants.USER_AGENT,
501
+ "Content-Type": "application/json",
502
+ },
503
+ timeout=30,
504
+ )
505
+
506
+ organization_id = None
507
+ if workspace_response.status_code == 200:
508
+ workspace_data = workspace_response.json()
509
+ organization_id = workspace_data.get("organizationId")
510
+
511
+ if organization_id:
512
+ result["scopedConfigs"] = get_all_scoped_configuration_contexts(
513
+ connector_id=connector_id,
514
+ actor_definition_id=actor_definition_id,
515
+ workspace_id=workspace_id,
516
+ organization_id=organization_id,
517
+ api_root=api_root,
518
+ bearer_token=access_token,
519
+ )
520
+
521
+ return result
522
+
313
523
 
314
524
  def set_connector_version_override(
315
525
  connector_id: str,
@@ -327,6 +537,17 @@ def set_connector_version_override(
327
537
  ) -> bool:
328
538
  """Set or clear a version override for a deployed connector.
329
539
 
540
+ This function checks all three scope levels (actor, workspace, organization) before
541
+ creating a new override to prevent duplicate key constraint violations. If an override
542
+ already exists at the actor scope with a different version, it will be deleted first
543
+ before creating the new one.
544
+
545
+ Note:
546
+ Race condition caveat: The check-then-delete-then-create sequence is not atomic.
547
+ If two concurrent requests attempt to set version overrides for the same connector,
548
+ both could pass the existence check and one could still fail with a duplicate key
549
+ error. The improved 500 error message provides guidance in this case.
550
+
330
551
  Args:
331
552
  connector_id: The ID of the deployed connector
332
553
  connector_type: Either "source" or "destination"
@@ -375,7 +596,7 @@ def set_connector_version_override(
375
596
  json=get_payload,
376
597
  headers={
377
598
  "Authorization": f"Bearer {access_token}",
378
- "User-Agent": "PyAirbyte Client",
599
+ "User-Agent": ops_constants.USER_AGENT,
379
600
  "Content-Type": "application/json",
380
601
  },
381
602
  timeout=30,
@@ -394,43 +615,15 @@ def set_connector_version_override(
394
615
  message=f"Could not find {definition_id_key} in {connector_type} info",
395
616
  )
396
617
 
397
- # Now get the scoped configuration context
398
- endpoint = f"{api_root}/scoped_configuration/get_context"
399
- context_payload = {
400
- "config_key": _ScopedConfigKey.CONNECTOR_VERSION,
401
- "resource_type": _ResourceType.ACTOR_DEFINITION,
402
- "resource_id": actor_definition_id,
403
- "scope_type": _ScopeType.ACTOR,
404
- "scope_id": connector_id,
405
- }
406
-
407
- response = requests.post(
408
- endpoint,
409
- json=context_payload,
410
- headers={
411
- "Authorization": f"Bearer {access_token}",
412
- "User-Agent": "PyAirbyte Client",
413
- "Content-Type": "application/json",
414
- },
415
- timeout=30,
618
+ # Use the shared helper to get the scoped configuration context at actor scope
619
+ active_config = _get_scoped_configuration_context(
620
+ actor_definition_id=actor_definition_id,
621
+ scope_type=_ScopeType.ACTOR,
622
+ scope_id=connector_id,
623
+ api_root=api_root,
624
+ access_token=access_token,
416
625
  )
417
626
 
418
- if response.status_code != 200:
419
- raise PyAirbyteInputError(
420
- message=f"Failed to get scoped configuration context: {response.status_code} {response.text}",
421
- context={
422
- "endpoint": endpoint,
423
- "payload": context_payload,
424
- "workspace_id": workspace_id,
425
- "connector_id": connector_id,
426
- "status_code": response.status_code,
427
- "response": response.text,
428
- },
429
- )
430
-
431
- context_data = response.json()
432
- active_config = context_data.get("activeConfiguration")
433
-
434
627
  if not active_config:
435
628
  # No override exists, nothing to do
436
629
  return False
@@ -444,7 +637,7 @@ def set_connector_version_override(
444
637
  json=delete_payload,
445
638
  headers={
446
639
  "Authorization": f"Bearer {access_token}",
447
- "User-Agent": "PyAirbyte Client",
640
+ "User-Agent": ops_constants.USER_AGENT,
448
641
  "Content-Type": "application/json",
449
642
  },
450
643
  timeout=30,
@@ -475,7 +668,7 @@ def set_connector_version_override(
475
668
  json=get_payload,
476
669
  headers={
477
670
  "Authorization": f"Bearer {access_token}",
478
- "User-Agent": "PyAirbyte Client",
671
+ "User-Agent": ops_constants.USER_AGENT,
479
672
  "Content-Type": "application/json",
480
673
  },
481
674
  timeout=30,
@@ -505,35 +698,217 @@ def set_connector_version_override(
505
698
  bearer_token=bearer_token,
506
699
  )
507
700
 
508
- # Get user ID from email if provided
509
- origin = None
510
- if user_email:
511
- origin = get_user_id_by_email(
512
- email=user_email,
513
- api_root=api_root,
514
- client_id=client_id,
515
- client_secret=client_secret,
516
- bearer_token=bearer_token,
701
+ # Get organization_id from workspace info for comprehensive scope checking
702
+ # This is REQUIRED - we must always check all three scopes
703
+ workspace_endpoint = f"{api_root}/workspaces/get"
704
+ workspace_payload = {"workspaceId": workspace_id}
705
+ workspace_response = requests.post(
706
+ workspace_endpoint,
707
+ json=workspace_payload,
708
+ headers={
709
+ "Authorization": f"Bearer {access_token}",
710
+ "User-Agent": ops_constants.USER_AGENT,
711
+ "Content-Type": "application/json",
712
+ },
713
+ timeout=30,
714
+ )
715
+ if workspace_response.status_code != 200:
716
+ raise PyAirbyteInputError(
717
+ message=f"Failed to get workspace info: {workspace_response.status_code} {workspace_response.text}. "
718
+ "Workspace info is required to determine organization_id for comprehensive scope checking.",
719
+ context={
720
+ "workspace_id": workspace_id,
721
+ "status_code": workspace_response.status_code,
722
+ "response": workspace_response.text,
723
+ },
724
+ )
725
+ workspace_info = workspace_response.json()
726
+ organization_id = workspace_info.get("organizationId")
727
+ if not organization_id:
728
+ raise PyAirbyteInputError(
729
+ message="Workspace does not have an organization_id. "
730
+ "Organization ID is required for comprehensive scope checking.",
731
+ context={
732
+ "workspace_id": workspace_id,
733
+ "workspace_info": workspace_info,
734
+ },
517
735
  )
518
736
 
737
+ # Check for existing scoped configuration at ALL scope levels BEFORE creating
738
+ # This prevents duplicate key constraint violations (500 errors) and provides
739
+ # clear messaging about where existing pins are set
740
+ all_configs = get_all_scoped_configuration_contexts(
741
+ connector_id=connector_id,
742
+ actor_definition_id=actor_definition_id,
743
+ workspace_id=workspace_id,
744
+ organization_id=organization_id,
745
+ api_root=api_root,
746
+ client_id=client_id,
747
+ client_secret=client_secret,
748
+ bearer_token=bearer_token,
749
+ )
750
+
751
+ # Check actor-level config first (most specific)
752
+ actor_config = all_configs.get(_ScopeType.ACTOR.value)
753
+ if actor_config:
754
+ existing_version_id = actor_config.get("value")
755
+ # API returns snake_case field names
756
+ existing_version_name = actor_config.get("value_name", "unknown")
757
+
758
+ # If already pinned to the same version at actor level, no action needed
759
+ if existing_version_id == version_id:
760
+ # Build detailed message with full context about the existing pin
761
+ # API returns snake_case field names
762
+ existing_description = actor_config.get(
763
+ "description", "No description provided"
764
+ )
765
+ existing_reference_url = actor_config.get(
766
+ "reference_url", "No reference URL"
767
+ )
768
+ existing_origin = actor_config.get("origin", "unknown")
769
+ existing_origin_type = actor_config.get("origin_type", "unknown")
770
+ existing_created_at = actor_config.get("created_at", "unknown")
771
+ existing_updated_at = actor_config.get("updated_at", "unknown")
772
+ existing_scope_name = actor_config.get("scope_name", "unknown")
773
+ existing_resource_name = actor_config.get("resource_name", "unknown")
774
+
775
+ detailed_message = (
776
+ f"Connector is already pinned to version {existing_version_name} "
777
+ f"(version_id: {existing_version_id}) at actor scope.\n\n"
778
+ f"EXISTING PIN DETAILS:\n"
779
+ f" - Config ID: {actor_config.get('id')}\n"
780
+ f" - Pinned Version: {existing_version_name}\n"
781
+ f" - Connector Name: {existing_scope_name}\n"
782
+ f" - Connector Definition: {existing_resource_name}\n"
783
+ f" - Description: {existing_description}\n"
784
+ f" - Reference URL: {existing_reference_url}\n"
785
+ f" - Origin Type: {existing_origin_type}\n"
786
+ f" - Origin: {existing_origin}\n"
787
+ f" - Created At: {existing_created_at}\n"
788
+ f" - Updated At: {existing_updated_at}\n\n"
789
+ f"No action needed. Use unset=True first if you want to re-pin to a different version."
790
+ )
791
+
792
+ raise PyAirbyteInputError(
793
+ message=detailed_message,
794
+ context={
795
+ "connector_id": connector_id,
796
+ "connector_type": connector_type,
797
+ "existing_version": existing_version_name,
798
+ "existing_version_id": existing_version_id,
799
+ "requested_version": version,
800
+ "requested_version_id": version_id,
801
+ "existing_config_id": actor_config.get("id"),
802
+ "existing_description": existing_description,
803
+ "existing_reference_url": existing_reference_url,
804
+ "existing_origin": existing_origin,
805
+ "existing_origin_type": existing_origin_type,
806
+ "existing_created_at": existing_created_at,
807
+ "existing_updated_at": existing_updated_at,
808
+ "scope_level": _ScopeType.ACTOR.value,
809
+ "scope_type_from_api": actor_config.get("scope_type"),
810
+ "scope_id_from_api": actor_config.get("scope_id"),
811
+ "full_existing_config": actor_config,
812
+ },
813
+ )
814
+
815
+ # If pinned to a different version at actor level, delete it first
816
+ # SAFETY GUARD: Only delete if the returned config is truly actor-scoped
817
+ # (not an inherited workspace/org config returned by the API)
818
+ api_scope_type = actor_config.get("scope_type", "").lower()
819
+ api_scope_id = actor_config.get("scope_id", "")
820
+ if api_scope_type != _ScopeType.ACTOR.value or api_scope_id != connector_id:
821
+ raise PyAirbyteInputError(
822
+ message=f"Cannot delete existing config: API returned a config that is not actor-scoped. "
823
+ f"Expected scope_type='{_ScopeType.ACTOR.value}' and scope_id='{connector_id}', "
824
+ f"but got scope_type='{api_scope_type}' and scope_id='{api_scope_id}'. "
825
+ f"This may be an inherited config from workspace/organization level.",
826
+ context={
827
+ "connector_id": connector_id,
828
+ "expected_scope_type": _ScopeType.ACTOR.value,
829
+ "expected_scope_id": connector_id,
830
+ "actual_scope_type": api_scope_type,
831
+ "actual_scope_id": api_scope_id,
832
+ "full_config": actor_config,
833
+ },
834
+ )
835
+
836
+ delete_endpoint = f"{api_root}/scoped_configuration/delete"
837
+ delete_payload = {"scopedConfigurationId": actor_config["id"]}
838
+
839
+ delete_response = requests.post(
840
+ delete_endpoint,
841
+ json=delete_payload,
842
+ headers={
843
+ "Authorization": f"Bearer {access_token}",
844
+ "User-Agent": ops_constants.USER_AGENT,
845
+ "Content-Type": "application/json",
846
+ },
847
+ timeout=30,
848
+ )
849
+
850
+ if delete_response.status_code not in (200, 204):
851
+ raise PyAirbyteInputError(
852
+ message=f"Failed to delete existing actor-level version override before setting new one: "
853
+ f"{delete_response.status_code} {delete_response.text}. "
854
+ f"Connector is currently pinned to {existing_version_name} at actor scope.",
855
+ context={
856
+ "delete_endpoint": delete_endpoint,
857
+ "config_id": actor_config["id"],
858
+ "existing_version": existing_version_name,
859
+ "requested_version": version,
860
+ "status_code": delete_response.status_code,
861
+ "response": delete_response.text,
862
+ "scope_level": _ScopeType.ACTOR.value,
863
+ },
864
+ )
865
+
866
+ # Report if there are pins at workspace or organization level (informational)
867
+ # These won't cause duplicate key errors but users should be aware of them
868
+ workspace_config = all_configs.get(_ScopeType.WORKSPACE.value)
869
+ org_config = all_configs.get(_ScopeType.ORGANIZATION.value)
870
+ inherited_pins: list[str] = []
871
+ if workspace_config:
872
+ ws_version = workspace_config.get("valueName", "unknown")
873
+ inherited_pins.append(f"workspace (version: {ws_version})")
874
+ if org_config:
875
+ org_version = org_config.get("valueName", "unknown")
876
+ inherited_pins.append(f"organization (version: {org_version})")
877
+
878
+ # Get user ID from email - required by API spec
879
+ if not user_email:
880
+ raise PyAirbyteInputError(
881
+ message="user_email is required to set a version override",
882
+ context={
883
+ "connector_id": connector_id,
884
+ "connector_type": connector_type,
885
+ },
886
+ )
887
+ origin = get_user_id_by_email(
888
+ email=user_email,
889
+ api_root=api_root,
890
+ client_id=client_id,
891
+ client_secret=client_secret,
892
+ bearer_token=bearer_token,
893
+ )
894
+
519
895
  # Create the override with correct schema
520
896
  endpoint = f"{api_root}/scoped_configuration/create"
521
897
 
898
+ # Build payload with explicit string values for auditability
899
+ # (enum.value ensures we log exactly what we send)
522
900
  payload: dict[str, Any] = {
523
- "config_key": _ScopedConfigKey.CONNECTOR_VERSION,
524
- "resource_type": _ResourceType.ACTOR_DEFINITION,
901
+ "config_key": _ScopedConfigKey.CONNECTOR_VERSION.value,
902
+ "resource_type": _ResourceType.ACTOR_DEFINITION.value,
525
903
  "resource_id": actor_definition_id,
526
- "scope_type": scope_type,
904
+ "scope_type": scope_type.value,
527
905
  "scope_id": connector_id,
528
906
  "value": version_id, # Use version ID, not version string
529
907
  "description": override_reason,
530
- "origin_type": _OriginType.USER,
908
+ "origin_type": _OriginType.USER.value,
909
+ "origin": origin,
531
910
  }
532
911
 
533
- # Add origin (user ID) if available
534
- if origin:
535
- payload["origin"] = origin
536
-
537
912
  if override_reason_reference_url:
538
913
  payload["reference_url"] = override_reason_reference_url
539
914
 
@@ -542,15 +917,28 @@ def set_connector_version_override(
542
917
  json=payload,
543
918
  headers={
544
919
  "Authorization": f"Bearer {access_token}",
545
- "User-Agent": "PyAirbyte Client",
920
+ "User-Agent": ops_constants.USER_AGENT,
546
921
  "Content-Type": "application/json",
547
922
  },
548
923
  timeout=30,
549
924
  )
550
925
 
551
926
  if response.status_code not in (200, 201):
927
+ # Provide helpful guidance for 500 errors which often indicate duplicates
928
+ error_message = f"Failed to set version override: {response.status_code} {response.text}"
929
+ if response.status_code == 500:
930
+ error_message += (
931
+ " A 500 error often indicates a duplicate key constraint violation - "
932
+ "the connector may already be pinned. Try using unset=True first, "
933
+ "or use the error lookup tool to get more details on the error ID."
934
+ )
935
+ if inherited_pins:
936
+ error_message += (
937
+ f" Note: Version pins were found at other scope levels: {', '.join(inherited_pins)}. "
938
+ "These inherited pins won't cause duplicate key errors but may affect the connector."
939
+ )
552
940
  raise PyAirbyteInputError(
553
- message=f"Failed to set version override: {response.status_code} {response.text}",
941
+ message=error_message,
554
942
  context={
555
943
  "connector_id": connector_id,
556
944
  "connector_type": connector_type,
@@ -561,6 +949,12 @@ def set_connector_version_override(
561
949
  "actor_definition_id": actor_definition_id,
562
950
  "status_code": response.status_code,
563
951
  "response": response.text,
952
+ "inherited_pins": inherited_pins if inherited_pins else None,
953
+ "all_scope_configs": {
954
+ k: {"id": v.get("id"), "valueName": v.get("valueName")}
955
+ for k, v in all_configs.items()
956
+ if v
957
+ },
564
958
  },
565
959
  )
566
960