airbyte-agent-hubspot 0.15.28__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 +101 -26
  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 +126 -17
  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 +28 -28
  31. airbyte_agent_hubspot/types.py +45 -45
  32. {airbyte_agent_hubspot-0.15.28.dist-info → airbyte_agent_hubspot-0.15.43.dist-info}/METADATA +16 -17
  33. {airbyte_agent_hubspot-0.15.28.dist-info → airbyte_agent_hubspot-0.15.43.dist-info}/RECORD +34 -32
  34. {airbyte_agent_hubspot-0.15.28.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(
@@ -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
@@ -844,10 +843,58 @@ class LocalExecutor:
844
843
  return self._extract_body(endpoint.body_fields, params)
845
844
  return None
846
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
+
847
893
  def _determine_request_format(self, endpoint: EndpointDefinition, body: dict[str, Any] | None) -> dict[str, Any]:
848
894
  """Determine json/data parameters for HTTP request.
849
895
 
850
896
  GraphQL always uses JSON, regardless of content_type setting.
897
+ For form-encoded requests, nested structures are flattened into bracket notation.
851
898
 
852
899
  Args:
853
900
  endpoint: Endpoint definition
@@ -864,7 +911,9 @@ class LocalExecutor:
864
911
  if is_graphql or endpoint.content_type.value == "application/json":
865
912
  return {"json": body}
866
913
  elif endpoint.content_type.value == "application/x-www-form-urlencoded":
867
- return {"data": body}
914
+ # Flatten nested structures for form encoding
915
+ flattened_body = self._flatten_form_data(body)
916
+ return {"data": flattened_body}
868
917
 
869
918
  return {}
870
919
 
@@ -1049,37 +1098,95 @@ class LocalExecutor:
1049
1098
 
1050
1099
  return interpolate_value(variables)
1051
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
+
1052
1155
  def _extract_records(
1053
1156
  self,
1054
- response_data: dict[str, Any],
1157
+ response_data: Any,
1055
1158
  endpoint: EndpointDefinition,
1056
- ) -> dict[str, Any] | list[Any] | None:
1159
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
1057
1160
  """Extract records from response using record extractor.
1058
1161
 
1059
1162
  Type inference based on action:
1060
1163
  - list, search: Returns array ([] if not found)
1061
1164
  - get, create, update, delete: Returns single record (None if not found)
1062
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
+
1063
1169
  Args:
1064
- response_data: Full API response
1170
+ response_data: Full API response (can be dict, list, primitive, or None)
1065
1171
  endpoint: Endpoint with optional record extractor and action
1066
1172
 
1067
1173
  Returns:
1068
1174
  - Extracted data if extractor configured and path found
1069
1175
  - [] or None if path not found (based on action)
1070
1176
  - Original response if no extractor configured or on error
1177
+ - Primitives are wrapped as {"value": primitive}
1071
1178
  """
1072
1179
  # Check if endpoint has record extractor
1073
1180
  extractor = endpoint.record_extractor
1074
1181
  if not extractor:
1075
- return response_data
1182
+ return self._wrap_primitives(response_data)
1076
1183
 
1077
1184
  # Determine if this action returns array or single record
1078
1185
  action = endpoint.action
1079
1186
  if not action:
1080
- return response_data
1187
+ return self._wrap_primitives(response_data)
1081
1188
 
1082
- is_array_action = action in (Action.LIST, Action.SEARCH)
1189
+ is_array_action = action in (Action.LIST, Action.API_SEARCH)
1083
1190
 
1084
1191
  try:
1085
1192
  # Parse and apply JSONPath expression
@@ -1090,17 +1197,19 @@ class LocalExecutor:
1090
1197
  # Path not found - return empty based on action
1091
1198
  return [] if is_array_action else None
1092
1199
 
1093
- # Return extracted data
1200
+ # Return extracted data with primitive wrapping
1094
1201
  if is_array_action:
1095
1202
  # For array actions, return the array (or list of matches)
1096
- return matches[0] if len(matches) == 1 else matches
1203
+ result = matches[0] if len(matches) == 1 else matches
1097
1204
  else:
1098
1205
  # For single record actions, return first match
1099
- return matches[0]
1206
+ result = matches[0]
1207
+
1208
+ return self._wrap_primitives(result)
1100
1209
 
1101
1210
  except Exception as e:
1102
1211
  logging.warning(f"Failed to apply record extractor '{extractor}': {e}. Returning original response.")
1103
- return response_data
1212
+ return self._wrap_primitives(response_data)
1104
1213
 
1105
1214
  def _extract_metadata(
1106
1215
  self,
@@ -1185,7 +1294,7 @@ class LocalExecutor:
1185
1294
 
1186
1295
  if missing_fields:
1187
1296
  raise MissingParameterError(
1188
- f"Missing required body fields for {entity}.{action.value}: {missing_fields}. " f"Provided parameters: {list(params.keys())}"
1297
+ f"Missing required body fields for {entity}.{action.value}: {missing_fields}. Provided parameters: {list(params.keys())}"
1189
1298
  )
1190
1299
 
1191
1300
  async def close(self):
@@ -1209,7 +1318,7 @@ class LocalExecutor:
1209
1318
 
1210
1319
 
1211
1320
  class _StandardOperationHandler:
1212
- """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)."""
1213
1322
 
1214
1323
  def __init__(self, context: _OperationContext):
1215
1324
  self.ctx = context
@@ -1222,7 +1331,7 @@ class _StandardOperationHandler:
1222
1331
  Action.CREATE,
1223
1332
  Action.UPDATE,
1224
1333
  Action.DELETE,
1225
- Action.SEARCH,
1334
+ Action.API_SEARCH,
1226
1335
  Action.AUTHORIZE,
1227
1336
  }
1228
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(
@@ -0,0 +1,262 @@
1
+ """
2
+ Shared introspection utilities for connector metadata.
3
+
4
+ This module provides utilities for introspecting connector metadata,
5
+ generating descriptions, and formatting parameter signatures. These
6
+ functions are used by both the runtime decorators and the generated
7
+ connector code.
8
+
9
+ The module is designed to work with any object conforming to the
10
+ ConnectorModel and EndpointDefinition interfaces from connector_sdk.types.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Protocol
16
+
17
+ # Constants
18
+ MAX_EXAMPLE_QUESTIONS = 5 # Maximum number of example questions to include in description
19
+
20
+
21
+ class EndpointProtocol(Protocol):
22
+ """Protocol defining the expected interface for endpoint parameters.
23
+
24
+ This allows functions to work with any endpoint-like object
25
+ that has these attributes, including EndpointDefinition and mock objects.
26
+ """
27
+
28
+ path_params: list[str]
29
+ path_params_schema: dict[str, dict[str, Any]]
30
+ query_params: list[str]
31
+ query_params_schema: dict[str, dict[str, Any]]
32
+ body_fields: list[str]
33
+ request_schema: dict[str, Any] | None
34
+
35
+
36
+ class EntityProtocol(Protocol):
37
+ """Protocol defining the expected interface for entity definitions."""
38
+
39
+ name: str
40
+ actions: list[Any]
41
+ endpoints: dict[Any, EndpointProtocol]
42
+
43
+
44
+ class ConnectorModelProtocol(Protocol):
45
+ """Protocol defining the expected interface for connector model parameters.
46
+
47
+ This allows functions to work with any connector-like object
48
+ that has these attributes, including ConnectorModel and mock objects.
49
+ """
50
+
51
+ @property
52
+ def entities(self) -> list[EntityProtocol]: ...
53
+
54
+ @property
55
+ def openapi_spec(self) -> Any: ...
56
+
57
+
58
+ def format_param_signature(endpoint: EndpointProtocol) -> str:
59
+ """Format parameter signature for an endpoint action.
60
+
61
+ Returns a string like: (id*) or (limit?, starting_after?, email?)
62
+ where * = required, ? = optional
63
+
64
+ Args:
65
+ endpoint: Object conforming to EndpointProtocol (e.g., EndpointDefinition)
66
+
67
+ Returns:
68
+ Formatted parameter signature string
69
+ """
70
+ params = []
71
+
72
+ # Defensive: safely access attributes with defaults for malformed endpoints
73
+ path_params = getattr(endpoint, "path_params", []) or []
74
+ query_params = getattr(endpoint, "query_params", []) or []
75
+ query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
76
+ body_fields = getattr(endpoint, "body_fields", []) or []
77
+ request_schema = getattr(endpoint, "request_schema", None)
78
+
79
+ # Path params (always required)
80
+ for name in path_params:
81
+ params.append(f"{name}*")
82
+
83
+ # Query params
84
+ for name in query_params:
85
+ schema = query_params_schema.get(name, {})
86
+ required = schema.get("required", False)
87
+ params.append(f"{name}{'*' if required else '?'}")
88
+
89
+ # Body fields
90
+ if request_schema:
91
+ required_fields = set(request_schema.get("required", []))
92
+ for name in body_fields:
93
+ params.append(f"{name}{'*' if name in required_fields else '?'}")
94
+
95
+ return f"({', '.join(params)})" if params else "()"
96
+
97
+
98
+ def describe_entities(model: ConnectorModelProtocol) -> list[dict[str, Any]]:
99
+ """Generate entity descriptions from ConnectorModel.
100
+
101
+ Returns a list of entity descriptions with detailed parameter information
102
+ for each action. This is used by generated connectors' describe() method.
103
+
104
+ Args:
105
+ model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
106
+
107
+ Returns:
108
+ List of entity description dicts with keys:
109
+ - entity_name: Name of the entity (e.g., "contacts", "deals")
110
+ - description: Entity description from the first endpoint
111
+ - available_actions: List of actions (e.g., ["list", "get", "create"])
112
+ - parameters: Dict mapping action -> list of parameter dicts
113
+ """
114
+ entities = []
115
+ for entity_def in model.entities:
116
+ description = ""
117
+ parameters: dict[str, list[dict[str, Any]]] = {}
118
+
119
+ endpoints = getattr(entity_def, "endpoints", {}) or {}
120
+ if endpoints:
121
+ for action, endpoint in endpoints.items():
122
+ # Get description from first endpoint that has one
123
+ if not description:
124
+ endpoint_desc = getattr(endpoint, "description", None)
125
+ if endpoint_desc:
126
+ description = endpoint_desc
127
+
128
+ action_params: list[dict[str, Any]] = []
129
+
130
+ # Defensive: safely access endpoint attributes
131
+ path_params = getattr(endpoint, "path_params", []) or []
132
+ path_params_schema = getattr(endpoint, "path_params_schema", {}) or {}
133
+ query_params = getattr(endpoint, "query_params", []) or []
134
+ query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
135
+ body_fields = getattr(endpoint, "body_fields", []) or []
136
+ request_schema = getattr(endpoint, "request_schema", None)
137
+
138
+ # Path params (always required)
139
+ for param_name in path_params:
140
+ schema = path_params_schema.get(param_name, {})
141
+ action_params.append(
142
+ {
143
+ "name": param_name,
144
+ "in": "path",
145
+ "required": True,
146
+ "type": schema.get("type", "string"),
147
+ "description": schema.get("description", ""),
148
+ }
149
+ )
150
+
151
+ # Query params
152
+ for param_name in query_params:
153
+ schema = query_params_schema.get(param_name, {})
154
+ action_params.append(
155
+ {
156
+ "name": param_name,
157
+ "in": "query",
158
+ "required": schema.get("required", False),
159
+ "type": schema.get("type", "string"),
160
+ "description": schema.get("description", ""),
161
+ }
162
+ )
163
+
164
+ # Body fields
165
+ if request_schema:
166
+ required_fields = request_schema.get("required", [])
167
+ properties = request_schema.get("properties", {})
168
+ for param_name in body_fields:
169
+ prop = properties.get(param_name, {})
170
+ action_params.append(
171
+ {
172
+ "name": param_name,
173
+ "in": "body",
174
+ "required": param_name in required_fields,
175
+ "type": prop.get("type", "string"),
176
+ "description": prop.get("description", ""),
177
+ }
178
+ )
179
+
180
+ if action_params:
181
+ # Action is an enum, use .value to get string
182
+ action_key = action.value if hasattr(action, "value") else str(action)
183
+ parameters[action_key] = action_params
184
+
185
+ actions = getattr(entity_def, "actions", []) or []
186
+ entities.append(
187
+ {
188
+ "entity_name": entity_def.name,
189
+ "description": description,
190
+ "available_actions": [a.value if hasattr(a, "value") else str(a) for a in actions],
191
+ "parameters": parameters,
192
+ }
193
+ )
194
+
195
+ return entities
196
+
197
+
198
+ def generate_tool_description(model: ConnectorModelProtocol) -> str:
199
+ """Generate AI tool description from connector metadata.
200
+
201
+ Produces a detailed description that includes:
202
+ - Per-entity/action parameter signatures with required (*) and optional (?) markers
203
+ - Response structure documentation with pagination hints
204
+ - Example questions if available in the OpenAPI spec
205
+
206
+ This is used by the Connector.describe class method decorator to populate
207
+ function docstrings for AI framework integration.
208
+
209
+ Args:
210
+ model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
211
+
212
+ Returns:
213
+ Formatted description string suitable for AI tool documentation
214
+ """
215
+ lines = []
216
+
217
+ # Entity/action parameter details (including pagination params like limit, starting_after)
218
+ lines.append("ENTITIES AND PARAMETERS:")
219
+ for entity in model.entities:
220
+ lines.append(f" {entity.name}:")
221
+ actions = getattr(entity, "actions", []) or []
222
+ endpoints = getattr(entity, "endpoints", {}) or {}
223
+ for action in actions:
224
+ action_str = action.value if hasattr(action, "value") else str(action)
225
+ endpoint = endpoints.get(action)
226
+ if endpoint:
227
+ param_sig = format_param_signature(endpoint)
228
+ lines.append(f" - {action_str}{param_sig}")
229
+ else:
230
+ lines.append(f" - {action_str}()")
231
+
232
+ # Response structure (brief, includes pagination hint)
233
+ lines.append("")
234
+ lines.append("RESPONSE STRUCTURE:")
235
+ lines.append(" - list/api_search: {data: [...], meta: {has_more: bool}}")
236
+ lines.append(" - get: Returns entity directly (no envelope)")
237
+ lines.append(" To paginate: pass starting_after=<last_id> while has_more is true")
238
+
239
+ # Add example questions if available in openapi_spec
240
+ openapi_spec = getattr(model, "openapi_spec", None)
241
+ if openapi_spec:
242
+ info = getattr(openapi_spec, "info", None)
243
+ if info:
244
+ example_questions = getattr(info, "x_airbyte_example_questions", None)
245
+ if example_questions:
246
+ supported = getattr(example_questions, "supported", None)
247
+ if supported:
248
+ lines.append("")
249
+ lines.append("EXAMPLE QUESTIONS:")
250
+ for q in supported[:MAX_EXAMPLE_QUESTIONS]:
251
+ lines.append(f" - {q}")
252
+
253
+ # Generic parameter description for function signature
254
+ lines.append("")
255
+ lines.append("FUNCTION PARAMETERS:")
256
+ lines.append(" - entity: Entity name (string)")
257
+ lines.append(" - action: Operation to perform (string)")
258
+ lines.append(" - params: Operation parameters (dict) - see entity details above")
259
+ lines.append("")
260
+ lines.append("Parameter markers: * = required, ? = optional")
261
+
262
+ return "\n".join(lines)
@@ -5,7 +5,7 @@ import json
5
5
  import time
6
6
  import uuid
7
7
  from pathlib import Path
8
- from typing import Any, Dict, Optional, Set
8
+ from typing import Any, Dict, Set
9
9
 
10
10
  from .types import LogSession, RequestLog
11
11
 
@@ -31,9 +31,9 @@ class RequestLogger:
31
31
 
32
32
  def __init__(
33
33
  self,
34
- log_file: Optional[str] = None,
35
- connector_name: Optional[str] = None,
36
- max_logs: Optional[int] = 10000,
34
+ log_file: str | None = None,
35
+ connector_name: str | None = None,
36
+ max_logs: int | None = 10000,
37
37
  ):
38
38
  """
39
39
  Initialize the request logger.
@@ -99,9 +99,9 @@ class RequestLogger:
99
99
  method: str,
100
100
  url: str,
101
101
  path: str,
102
- headers: Optional[Dict[str, str]] = None,
103
- params: Optional[Dict[str, Any]] = None,
104
- body: Optional[Any] = None,
102
+ headers: Dict[str, str] | None = None,
103
+ params: Dict[str, Any] | None = None,
104
+ body: Any | None = None,
105
105
  ) -> str:
106
106
  """
107
107
  Log the start of an HTTP request.
@@ -133,7 +133,7 @@ class RequestLogger:
133
133
  self,
134
134
  request_id: str,
135
135
  status_code: int,
136
- response_body: Optional[Any] = None,
136
+ response_body: Any | None = None,
137
137
  ) -> None:
138
138
  """
139
139
  Log a successful HTTP response.
@@ -176,7 +176,7 @@ class RequestLogger:
176
176
  self,
177
177
  request_id: str,
178
178
  error: str,
179
- status_code: Optional[int] = None,
179
+ status_code: int | None = None,
180
180
  ) -> None:
181
181
  """
182
182
  Log an HTTP request error.