airbyte-agent-hubspot 0.15.25__py3-none-any.whl → 0.15.43__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.
Files changed (34) hide show
  1. airbyte_agent_hubspot/__init__.py +100 -25
  2. airbyte_agent_hubspot/_vendored/connector_sdk/auth_strategies.py +2 -5
  3. airbyte_agent_hubspot/_vendored/connector_sdk/auth_template.py +1 -1
  4. airbyte_agent_hubspot/_vendored/connector_sdk/cloud_utils/client.py +26 -26
  5. airbyte_agent_hubspot/_vendored/connector_sdk/connector_model_loader.py +11 -4
  6. airbyte_agent_hubspot/_vendored/connector_sdk/constants.py +1 -1
  7. airbyte_agent_hubspot/_vendored/connector_sdk/executor/hosted_executor.py +10 -11
  8. airbyte_agent_hubspot/_vendored/connector_sdk/executor/local_executor.py +163 -34
  9. airbyte_agent_hubspot/_vendored/connector_sdk/extensions.py +43 -5
  10. airbyte_agent_hubspot/_vendored/connector_sdk/http/response.py +2 -0
  11. airbyte_agent_hubspot/_vendored/connector_sdk/introspection.py +262 -0
  12. airbyte_agent_hubspot/_vendored/connector_sdk/logging/logger.py +9 -9
  13. airbyte_agent_hubspot/_vendored/connector_sdk/logging/types.py +10 -10
  14. airbyte_agent_hubspot/_vendored/connector_sdk/observability/config.py +179 -0
  15. airbyte_agent_hubspot/_vendored/connector_sdk/observability/models.py +6 -6
  16. airbyte_agent_hubspot/_vendored/connector_sdk/observability/session.py +41 -32
  17. airbyte_agent_hubspot/_vendored/connector_sdk/performance/metrics.py +3 -3
  18. airbyte_agent_hubspot/_vendored/connector_sdk/schema/base.py +20 -18
  19. airbyte_agent_hubspot/_vendored/connector_sdk/schema/components.py +59 -58
  20. airbyte_agent_hubspot/_vendored/connector_sdk/schema/connector.py +22 -33
  21. airbyte_agent_hubspot/_vendored/connector_sdk/schema/extensions.py +103 -10
  22. airbyte_agent_hubspot/_vendored/connector_sdk/schema/operations.py +32 -32
  23. airbyte_agent_hubspot/_vendored/connector_sdk/schema/security.py +44 -34
  24. airbyte_agent_hubspot/_vendored/connector_sdk/secrets.py +2 -2
  25. airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/events.py +9 -8
  26. airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/tracker.py +9 -5
  27. airbyte_agent_hubspot/_vendored/connector_sdk/types.py +7 -3
  28. airbyte_agent_hubspot/connector.py +182 -87
  29. airbyte_agent_hubspot/connector_model.py +17 -12
  30. airbyte_agent_hubspot/models.py +21 -21
  31. airbyte_agent_hubspot/types.py +45 -45
  32. {airbyte_agent_hubspot-0.15.25.dist-info → airbyte_agent_hubspot-0.15.43.dist-info}/METADATA +25 -22
  33. {airbyte_agent_hubspot-0.15.25.dist-info → airbyte_agent_hubspot-0.15.43.dist-info}/RECORD +34 -32
  34. {airbyte_agent_hubspot-0.15.25.dist-info → airbyte_agent_hubspot-0.15.43.dist-info}/WHEEL +0 -0
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import inspect
6
7
  import logging
7
8
  import os
8
9
  import re
@@ -11,6 +12,7 @@ from collections.abc import AsyncIterator
11
12
  from typing import Any, Protocol
12
13
  from urllib.parse import quote
13
14
 
15
+ from jinja2 import Environment, StrictUndefined
14
16
  from jsonpath_ng import parse as parse_jsonpath
15
17
  from opentelemetry import trace
16
18
 
@@ -506,8 +508,6 @@ class LocalExecutor:
506
508
  result = handler.execute_operation(config.entity, action, params)
507
509
 
508
510
  # Check if it's an async generator (download) or awaitable (standard)
509
- import inspect
510
-
511
511
  if inspect.isasyncgen(result):
512
512
  # Download operation: return generator directly
513
513
  return ExecutionResult(
@@ -674,16 +674,16 @@ class LocalExecutor:
674
674
  return {key: value for key, value in params.items() if key in allowed_params}
675
675
 
676
676
  def _extract_body(self, allowed_fields: list[str], params: dict[str, Any]) -> dict[str, Any]:
677
- """Extract body fields from params.
677
+ """Extract body fields from params, filtering out None values.
678
678
 
679
679
  Args:
680
680
  allowed_fields: List of allowed body field names
681
681
  params: All parameters
682
682
 
683
683
  Returns:
684
- Dictionary of body fields
684
+ Dictionary of body fields with None values filtered out
685
685
  """
686
- return {key: value for key, value in params.items() if key in allowed_fields}
686
+ return {key: value for key, value in params.items() if key in allowed_fields and value is not None}
687
687
 
688
688
  def _serialize_deep_object_params(self, params: dict[str, Any], deep_object_param_names: list[str]) -> dict[str, Any]:
689
689
  """Serialize deepObject parameters to bracket notation format.
@@ -814,7 +814,6 @@ class LocalExecutor:
814
814
  >>> _substitute_file_field_params("attachments[{attachment_index}].url", {"attachment_index": 0})
815
815
  "attachments[0].url"
816
816
  """
817
- from jinja2 import Environment, StrictUndefined
818
817
 
819
818
  # Use custom delimiters to match OpenAPI path parameter syntax {var}
820
819
  # StrictUndefined raises clear error if a template variable is missing
@@ -837,15 +836,65 @@ class LocalExecutor:
837
836
  Request body dict or None if no body needed
838
837
  """
839
838
  if endpoint.graphql_body:
840
- return self._build_graphql_body(endpoint.graphql_body, params)
839
+ # Extract defaults from query_params_schema for GraphQL variable interpolation
840
+ param_defaults = {name: schema.get("default") for name, schema in endpoint.query_params_schema.items() if "default" in schema}
841
+ return self._build_graphql_body(endpoint.graphql_body, params, param_defaults)
841
842
  elif endpoint.body_fields:
842
843
  return self._extract_body(endpoint.body_fields, params)
843
844
  return None
844
845
 
846
+ def _flatten_form_data(self, data: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
847
+ """Flatten nested dict/list structures into bracket notation for form encoding.
848
+
849
+ Stripe and similar APIs require nested arrays/objects to be encoded using bracket
850
+ notation when using application/x-www-form-urlencoded content type.
851
+
852
+ Args:
853
+ data: Nested dict with arrays/objects to flatten
854
+ parent_key: Parent key for nested structures (used in recursion)
855
+
856
+ Returns:
857
+ Flattened dict with bracket notation keys
858
+
859
+ Examples:
860
+ >>> _flatten_form_data({"items": [{"price": "p1", "qty": 1}]})
861
+ {"items[0][price]": "p1", "items[0][qty]": 1}
862
+
863
+ >>> _flatten_form_data({"customer": "cus_123", "metadata": {"key": "value"}})
864
+ {"customer": "cus_123", "metadata[key]": "value"}
865
+ """
866
+ flattened = {}
867
+
868
+ for key, value in data.items():
869
+ new_key = f"{parent_key}[{key}]" if parent_key else key
870
+
871
+ if isinstance(value, dict):
872
+ # Recursively flatten nested dicts
873
+ flattened.update(self._flatten_form_data(value, new_key))
874
+ elif isinstance(value, list):
875
+ # Flatten arrays with indexed bracket notation
876
+ for i, item in enumerate(value):
877
+ indexed_key = f"{new_key}[{i}]"
878
+ if isinstance(item, dict):
879
+ # Nested dict in array - recurse
880
+ flattened.update(self._flatten_form_data(item, indexed_key))
881
+ elif isinstance(item, list):
882
+ # Nested list in array - recurse
883
+ flattened.update(self._flatten_form_data({str(i): item}, new_key))
884
+ else:
885
+ # Primitive value in array
886
+ flattened[indexed_key] = item
887
+ else:
888
+ # Primitive value - add directly
889
+ flattened[new_key] = value
890
+
891
+ return flattened
892
+
845
893
  def _determine_request_format(self, endpoint: EndpointDefinition, body: dict[str, Any] | None) -> dict[str, Any]:
846
894
  """Determine json/data parameters for HTTP request.
847
895
 
848
896
  GraphQL always uses JSON, regardless of content_type setting.
897
+ For form-encoded requests, nested structures are flattened into bracket notation.
849
898
 
850
899
  Args:
851
900
  endpoint: Endpoint definition
@@ -862,7 +911,9 @@ class LocalExecutor:
862
911
  if is_graphql or endpoint.content_type.value == "application/json":
863
912
  return {"json": body}
864
913
  elif endpoint.content_type.value == "application/x-www-form-urlencoded":
865
- return {"data": body}
914
+ # Flatten nested structures for form encoding
915
+ flattened_body = self._flatten_form_data(body)
916
+ return {"data": flattened_body}
866
917
 
867
918
  return {}
868
919
 
@@ -903,12 +954,18 @@ class LocalExecutor:
903
954
 
904
955
  return query
905
956
 
906
- def _build_graphql_body(self, graphql_config: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
957
+ def _build_graphql_body(
958
+ self,
959
+ graphql_config: dict[str, Any],
960
+ params: dict[str, Any],
961
+ param_defaults: dict[str, Any] | None = None,
962
+ ) -> dict[str, Any]:
907
963
  """Build GraphQL request body with variable substitution and field selection.
908
964
 
909
965
  Args:
910
966
  graphql_config: GraphQL configuration from x-airbyte-body-type extension
911
967
  params: Parameters from execute() call
968
+ param_defaults: Default values for params from query_params_schema
912
969
 
913
970
  Returns:
914
971
  GraphQL request body: {"query": "...", "variables": {...}}
@@ -922,7 +979,7 @@ class LocalExecutor:
922
979
 
923
980
  # Substitute variables from params
924
981
  if "variables" in graphql_config and graphql_config["variables"]:
925
- body["variables"] = self._interpolate_variables(graphql_config["variables"], params)
982
+ body["variables"] = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
926
983
 
927
984
  # Add operation name if specified
928
985
  if "operationName" in graphql_config:
@@ -981,7 +1038,12 @@ class LocalExecutor:
981
1038
  fields_str = " ".join(graphql_fields)
982
1039
  return query.replace("{{ fields }}", fields_str)
983
1040
 
984
- def _interpolate_variables(self, variables: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
1041
+ def _interpolate_variables(
1042
+ self,
1043
+ variables: dict[str, Any],
1044
+ params: dict[str, Any],
1045
+ param_defaults: dict[str, Any] | None = None,
1046
+ ) -> dict[str, Any]:
985
1047
  """Recursively interpolate variables using params.
986
1048
 
987
1049
  Preserves types (doesn't stringify everything).
@@ -990,15 +1052,18 @@ class LocalExecutor:
990
1052
  - Direct replacement: "{{ owner }}" → params["owner"] (preserves type)
991
1053
  - Nested objects: {"input": {"name": "{{ name }}"}}
992
1054
  - Arrays: [{"id": "{{ id }}"}]
993
- - Unsubstituted placeholders: "{{ states }}" → None (for optional params)
1055
+ - Default values: "{{ per_page }}" → param_defaults["per_page"] if not in params
1056
+ - Unsubstituted placeholders: "{{ states }}" → None (for optional params without defaults)
994
1057
 
995
1058
  Args:
996
1059
  variables: Variables dict with template placeholders
997
1060
  params: Parameters to substitute
1061
+ param_defaults: Default values for params from query_params_schema
998
1062
 
999
1063
  Returns:
1000
1064
  Interpolated variables dict with types preserved
1001
1065
  """
1066
+ defaults = param_defaults or {}
1002
1067
 
1003
1068
  def interpolate_value(value: Any) -> Any:
1004
1069
  if isinstance(value, str):
@@ -1012,8 +1077,15 @@ class LocalExecutor:
1012
1077
  value = value.replace(placeholder, str(param_value))
1013
1078
 
1014
1079
  # Check if any unsubstituted placeholders remain
1015
- # If so, return None (treats as "not provided" for optional params)
1016
1080
  if re.search(r"\{\{\s*\w+\s*\}\}", value):
1081
+ # Extract placeholder name and check for default value
1082
+ match = re.search(r"\{\{\s*(\w+)\s*\}\}", value)
1083
+ if match:
1084
+ param_name = match.group(1)
1085
+ if param_name in defaults:
1086
+ # Use default value (preserves type)
1087
+ return defaults[param_name]
1088
+ # No default found - return None (for optional params)
1017
1089
  return None
1018
1090
 
1019
1091
  return value
@@ -1026,37 +1098,95 @@ class LocalExecutor:
1026
1098
 
1027
1099
  return interpolate_value(variables)
1028
1100
 
1101
+ def _wrap_primitives(self, data: Any) -> dict[str, Any] | list[dict[str, Any]] | None:
1102
+ """Wrap primitive values in dict format for consistent response structure.
1103
+
1104
+ Transforms primitive API responses into dict format so downstream code
1105
+ can always expect dict-based data structures.
1106
+
1107
+ Args:
1108
+ data: Response data (could be primitive, list, dict, or None)
1109
+
1110
+ Returns:
1111
+ - If data is a primitive (str, int, float, bool): {"value": data}
1112
+ - If data is a list: wraps all non-dict elements as {"value": item}
1113
+ - If data is already a dict or list of dicts: unchanged
1114
+ - If data is None: None
1115
+
1116
+ Examples:
1117
+ >>> executor._wrap_primitives(42)
1118
+ {"value": 42}
1119
+ >>> executor._wrap_primitives([1, 2, 3])
1120
+ [{"value": 1}, {"value": 2}, {"value": 3}]
1121
+ >>> executor._wrap_primitives([1, {"id": 2}, 3])
1122
+ [{"value": 1}, {"id": 2}, {"value": 3}]
1123
+ >>> executor._wrap_primitives([[1, 2], 3])
1124
+ [{"value": [1, 2]}, {"value": 3}]
1125
+ >>> executor._wrap_primitives({"id": 1})
1126
+ {"id": 1} # unchanged
1127
+ """
1128
+ if data is None:
1129
+ return None
1130
+
1131
+ # Handle primitive scalars
1132
+ if isinstance(data, (bool, str, int, float)):
1133
+ return {"value": data}
1134
+
1135
+ # Handle lists - wrap non-dict elements
1136
+ if isinstance(data, list):
1137
+ if not data:
1138
+ return [] # Empty list unchanged
1139
+
1140
+ wrapped = []
1141
+ for item in data:
1142
+ if isinstance(item, dict):
1143
+ wrapped.append(item)
1144
+ else:
1145
+ wrapped.append({"value": item})
1146
+ return wrapped
1147
+
1148
+ # Dict - return unchanged
1149
+ if isinstance(data, dict):
1150
+ return data
1151
+
1152
+ # Unknown type - wrap for safety
1153
+ return {"value": data}
1154
+
1029
1155
  def _extract_records(
1030
1156
  self,
1031
- response_data: dict[str, Any],
1157
+ response_data: Any,
1032
1158
  endpoint: EndpointDefinition,
1033
- ) -> dict[str, Any] | list[Any] | None:
1159
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
1034
1160
  """Extract records from response using record extractor.
1035
1161
 
1036
1162
  Type inference based on action:
1037
1163
  - list, search: Returns array ([] if not found)
1038
1164
  - get, create, update, delete: Returns single record (None if not found)
1039
1165
 
1166
+ Automatically wraps primitive values (int, str, float, bool) in {"value": primitive}
1167
+ format to ensure consistent dict-based responses for downstream code.
1168
+
1040
1169
  Args:
1041
- response_data: Full API response
1170
+ response_data: Full API response (can be dict, list, primitive, or None)
1042
1171
  endpoint: Endpoint with optional record extractor and action
1043
1172
 
1044
1173
  Returns:
1045
1174
  - Extracted data if extractor configured and path found
1046
1175
  - [] or None if path not found (based on action)
1047
1176
  - Original response if no extractor configured or on error
1177
+ - Primitives are wrapped as {"value": primitive}
1048
1178
  """
1049
1179
  # Check if endpoint has record extractor
1050
1180
  extractor = endpoint.record_extractor
1051
1181
  if not extractor:
1052
- return response_data
1182
+ return self._wrap_primitives(response_data)
1053
1183
 
1054
1184
  # Determine if this action returns array or single record
1055
1185
  action = endpoint.action
1056
1186
  if not action:
1057
- return response_data
1187
+ return self._wrap_primitives(response_data)
1058
1188
 
1059
- is_array_action = action in (Action.LIST, Action.SEARCH)
1189
+ is_array_action = action in (Action.LIST, Action.API_SEARCH)
1060
1190
 
1061
1191
  try:
1062
1192
  # Parse and apply JSONPath expression
@@ -1067,17 +1197,19 @@ class LocalExecutor:
1067
1197
  # Path not found - return empty based on action
1068
1198
  return [] if is_array_action else None
1069
1199
 
1070
- # Return extracted data
1200
+ # Return extracted data with primitive wrapping
1071
1201
  if is_array_action:
1072
1202
  # For array actions, return the array (or list of matches)
1073
- return matches[0] if len(matches) == 1 else matches
1203
+ result = matches[0] if len(matches) == 1 else matches
1074
1204
  else:
1075
1205
  # For single record actions, return first match
1076
- return matches[0]
1206
+ result = matches[0]
1207
+
1208
+ return self._wrap_primitives(result)
1077
1209
 
1078
1210
  except Exception as e:
1079
1211
  logging.warning(f"Failed to apply record extractor '{extractor}': {e}. Returning original response.")
1080
- return response_data
1212
+ return self._wrap_primitives(response_data)
1081
1213
 
1082
1214
  def _extract_metadata(
1083
1215
  self,
@@ -1151,17 +1283,14 @@ class LocalExecutor:
1151
1283
  if action not in (Action.CREATE, Action.UPDATE):
1152
1284
  return
1153
1285
 
1154
- # Check if endpoint has body fields defined
1155
- if not endpoint.body_fields:
1286
+ # Get the request schema to find truly required fields
1287
+ request_schema = endpoint.request_schema
1288
+ if not request_schema:
1156
1289
  return
1157
1290
 
1158
- # For now, we treat all body_fields as potentially required for CREATE/UPDATE
1159
- # In a more advanced implementation, we could parse the request schema
1160
- # to identify truly required fields
1161
- missing_fields = []
1162
- for field in endpoint.body_fields:
1163
- if field not in params:
1164
- missing_fields.append(field)
1291
+ # Only validate fields explicitly marked as required in the schema
1292
+ required_fields = request_schema.get("required", [])
1293
+ missing_fields = [field for field in required_fields if field not in params]
1165
1294
 
1166
1295
  if missing_fields:
1167
1296
  raise MissingParameterError(
@@ -1189,7 +1318,7 @@ class LocalExecutor:
1189
1318
 
1190
1319
 
1191
1320
  class _StandardOperationHandler:
1192
- """Handler for standard REST operations (GET, LIST, CREATE, UPDATE, DELETE, SEARCH, AUTHORIZE)."""
1321
+ """Handler for standard REST operations (GET, LIST, CREATE, UPDATE, DELETE, API_SEARCH, AUTHORIZE)."""
1193
1322
 
1194
1323
  def __init__(self, context: _OperationContext):
1195
1324
  self.ctx = context
@@ -1202,7 +1331,7 @@ class _StandardOperationHandler:
1202
1331
  Action.CREATE,
1203
1332
  Action.UPDATE,
1204
1333
  Action.DELETE,
1205
- Action.SEARCH,
1334
+ Action.API_SEARCH,
1206
1335
  Action.AUTHORIZE,
1207
1336
  }
1208
1337
 
@@ -159,6 +159,38 @@ Example:
159
159
  ```
160
160
  """
161
161
 
162
+ AIRBYTE_STREAM_NAME = "x-airbyte-stream-name"
163
+ """
164
+ Extension: x-airbyte-stream-name
165
+ Location: Schema object (in components.schemas)
166
+ Type: string
167
+ Required: No
168
+
169
+ Description:
170
+ Specifies the Airbyte stream name for cache lookup purposes. This maps the entity
171
+ to the corresponding Airbyte stream, enabling cache-based data retrieval. When
172
+ specified, the EntityDefinition.stream_name field will be populated with this value.
173
+
174
+ This extension is placed on Schema objects alongside x-airbyte-entity-name, following
175
+ the same pattern. The stream name is an entity-level property (not operation-level)
176
+ since an entity maps to exactly one Airbyte stream.
177
+
178
+ Example:
179
+ ```yaml
180
+ components:
181
+ schemas:
182
+ Customer:
183
+ type: object
184
+ x-airbyte-entity-name: customers
185
+ x-airbyte-stream-name: customers
186
+ properties:
187
+ id:
188
+ type: string
189
+ name:
190
+ type: string
191
+ ```
192
+ """
193
+
162
194
  AIRBYTE_TOKEN_PATH = "x-airbyte-token-path"
163
195
  """
164
196
  Extension: x-airbyte-token-path
@@ -495,8 +527,8 @@ class ActionType(str, Enum):
495
527
  DELETE = "delete"
496
528
  """Delete a record"""
497
529
 
498
- SEARCH = "search"
499
- """Search for records matching specific query criteria"""
530
+ API_SEARCH = "api_search"
531
+ """Search for records matching specific query criteria via API"""
500
532
 
501
533
  DOWNLOAD = "download"
502
534
  """Download file content from a URL specified in the metadata response"""
@@ -514,7 +546,7 @@ class BodyType(str, Enum):
514
546
 
515
547
 
516
548
  # Type alias for use in Pydantic models
517
- ActionTypeLiteral = Literal["get", "list", "create", "update", "delete", "search", "download"]
549
+ ActionTypeLiteral = Literal["get", "list", "create", "update", "delete", "api_search", "download"]
518
550
 
519
551
 
520
552
  # =============================================================================
@@ -548,6 +580,7 @@ def get_all_extension_names() -> list[str]:
548
580
  AIRBYTE_ENTITY,
549
581
  AIRBYTE_ACTION,
550
582
  AIRBYTE_ENTITY_NAME,
583
+ AIRBYTE_STREAM_NAME,
551
584
  AIRBYTE_TOKEN_PATH,
552
585
  AIRBYTE_BODY_TYPE,
553
586
  AIRBYTE_PATH_OVERRIDE,
@@ -594,6 +627,12 @@ EXTENSION_REGISTRY = {
594
627
  "required": False,
595
628
  "description": "Links schema to an entity/stream",
596
629
  },
630
+ AIRBYTE_STREAM_NAME: {
631
+ "location": "schema",
632
+ "type": "string",
633
+ "required": False,
634
+ "description": "Maps entity to Airbyte stream for cache lookup",
635
+ },
597
636
  AIRBYTE_TOKEN_PATH: {
598
637
  "location": "securityScheme",
599
638
  "type": "string",
@@ -627,8 +666,7 @@ EXTENSION_REGISTRY = {
627
666
  "type": "dict[str, str]",
628
667
  "required": False,
629
668
  "description": (
630
- "Dictionary mapping field names to JSONPath expressions for extracting metadata "
631
- "(pagination, request IDs, etc.) from response envelopes"
669
+ "Dictionary mapping field names to JSONPath expressions for extracting metadata (pagination, request IDs, etc.) from response envelopes"
632
670
  ),
633
671
  },
634
672
  AIRBYTE_FILE_URL: {
@@ -80,6 +80,8 @@ class HTTPResponse:
80
80
  HTTPStatusError: For 4xx or 5xx status codes.
81
81
  """
82
82
  if 400 <= self._status_code < 600:
83
+ # NOTE: Import here intentionally to avoid circular import.
84
+ # exceptions.py imports HTTPResponse for type hints.
83
85
  from .exceptions import HTTPStatusError
84
86
 
85
87
  raise HTTPStatusError(