diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cbb6d60..00a43e2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,61 @@ # Release Notes +## 2.0.1 + +### Behavior Change + +Auth validation now expects the platform auth envelope only. Flat auth is no +longer accepted. Production runtime behavior is unchanged (the platform has +always sent the envelope); this release fixes the SDK to validate the object +production actually sends. Local tests and scripts that construct flat auth +contexts must migrate to the wrapped shape. + +The platform delivers integration auth as a wrapped envelope: + +```json +{"auth_type": "Custom", "credentials": {"api_key": "...", "api_url": "..."}} +``` + +The `auth.fields` schema in `config.json` describes the inner `credentials` +object. Previously the SDK validated the *entire* envelope against +`auth.fields`, so any non-empty `required` list failed before the handler ran +(the ActiveCampaign outage), while an empty `required` list passed vacuously +even with empty credentials. + +As of 2.0.1 the SDK validates only `context.auth["credentials"]` against +`auth.fields`. `auth.fields.required` is now honoured and recommended. If +`context.auth` is not a wrapped envelope with a `credentials` dict, validation +fails with a `VALIDATION_ERROR` whose `source` is `"auth"`. + +**Before (2.x local test):** +```python +ctx = ExecutionContext(auth={"api_key": "..."}) # flat +``` + +**After (2.0.1):** +```python +ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "..."}}) +``` + +### Migration Guide + +1. **Wrap auth in local tests and any manual `ExecutionContext` construction** + in the `{"auth_type": ..., "credentials": {...}}` envelope. Production + traffic is already wrapped by the platform. +2. **Read credentials directly from the envelope** via + `context.auth["credentials"]`: + ```python + # Before + api_key = context.auth.get("api_key", "") + # After + api_key = context.auth["credentials"].get("api_key", "") + ``` +3. **Keep `auth.fields.required`** — it is now enforced against the credentials + object and is the recommended way to require credentials. +4. **Note on pins:** `~=2.0.0` pins resolve to 2.0.1 automatically on the next + rebuild; there is no flat-auth backward compatibility, so migrate tests + before rebuilding. Pin `~=2.0.1` to express the requirement explicitly. + ## 2.0.0 ### ⚠️ Breaking Change diff --git a/docs/apidocs/autohive_integrations_sdk.html b/docs/apidocs/autohive_integrations_sdk.html index 629c4e1..890eac2 100644 --- a/docs/apidocs/autohive_integrations_sdk.html +++ b/docs/apidocs/autohive_integrations_sdk.html @@ -51,7 +51,7 @@
1# Version -2__version__ = "2.0.0" +2__version__ = "2.0.1" 3 4# Re-export classes from integration module 5from autohive_integrations_sdk.integration import ( diff --git a/docs/apidocs/autohive_integrations_sdk/integration.html b/docs/apidocs/autohive_integrations_sdk/integration.html index 275975a..ad7c922 100644 --- a/docs/apidocs/autohive_integrations_sdk/integration.html +++ b/docs/apidocs/autohive_integrations_sdk/integration.html @@ -30,9 +30,6 @@API Documentation
-
35import json as jsonX # Keep alias to avoid conflict with 'json' parameter in fetch 36import logging 37import os - 38import re - 39import sys - 40from pathlib import Path - 41from typing import Dict, Any, List, Optional, Union, Type, TypeVar, Generic, ClassVar - 42from urllib.parse import urlencode - 43 - 44# Third-Party Imports - 45import aiohttp - 46from jsonschema import validate, Draft7Validator + 38import sys + 39from pathlib import Path + 40from typing import Dict, Any, List, Optional, Union, Type, TypeVar, Generic, ClassVar + 41from urllib.parse import urlencode + 42 + 43# Third-Party Imports + 44import aiohttp + 45from jsonschema import validate, Draft7Validator + 46 47 - 48 - 49# Local Imports - 50from autohive_integrations_sdk import __version__ + 48# Local Imports + 49from autohive_integrations_sdk import __version__ + 50 51 - 52 - 53# ---- Type Definitions ---- - 54T = TypeVar('T') - 55 - 56_USER_AGENT_TOKEN_RE = re.compile(r"[^A-Za-z0-9!#$%&'*+.^_`|~-]+") - 57 + 52# ---- Type Definitions ---- + 53T = TypeVar('T') + 54 + 55# ---- Auth Types ---- + 56class AuthType(Enum): + 57 """Authentication strategy used by an integration. 58 - 59def _sanitize_user_agent_token(value: Any) -> str: - 60 """Return a safe User-Agent product token component.""" - 61 token = _USER_AGENT_TOKEN_RE.sub("-", str(value)).strip("-") - 62 return token or "unknown" - 63 - 64 - 65DEFAULT_USER_AGENT = f"AutohiveIntegrationsSDK/{_sanitize_user_agent_token(__version__)}" - 66"""Default User-Agent sent by ``ExecutionContext.fetch()`` when not overridden.""" - 67 - 68# ---- Auth Types ---- - 69class AuthType(Enum): - 70 """Authentication strategy used by an integration. - 71 - 72 The platform stores the auth type alongside credentials and passes both - 73 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide - 74 whether to auto-inject an ``Authorization`` header. - 75 - 76 Members: - 77 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the - 78 token lifecycle and injects ``Bearer <access_token>`` automatically. - 79 PlatformTeams: Platform-managed Microsoft Teams auth. - 80 ApiKey: A single API key provided by the user. - 81 Basic: Username/password (HTTP Basic) credentials. - 82 Custom: Free-form credential fields defined by the integration's - 83 ``config.json`` auth schema. The integration is responsible for - 84 reading individual fields from ``context.auth``. - 85 """ - 86 PlatformOauth2 = "PlatformOauth2" - 87 PlatformTeams = "PlatformTeams" - 88 ApiKey = "ApiKey" - 89 Basic = "Basic" - 90 Custom = "Custom" - 91 - 92class ResultType(Enum): - 93 """Type of result being returned""" - 94 ACTION = "action" - 95 ACTION_ERROR = "action_error" - 96 CONNECTED_ACCOUNT = "connected_account" - 97 ERROR = "error" - 98 VALIDATION_ERROR = "validation_error" - 99 -100# ---- Exceptions ---- -101class ValidationError(Exception): -102 """Raised when SDK validation fails. -103 -104 This covers several cases: -105 -106 - Action inputs don't match the ``input_schema`` in ``config.json`` -107 - Action outputs don't match the ``output_schema`` -108 - Auth credentials don't match the ``auth.fields`` schema -109 - An action handler returns something other than ``ActionResult`` -110 - A handler name isn't registered -111 """ -112 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): -113 self.schema = schema -114 """The schema that failed validation""" -115 self.inputs = inputs -116 """The data that failed validation""" -117 self.message = message -118 """The error message""" -119 self.source = source -120 """Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)""" -121 super().__init__(message) -122 -123class ConfigurationError(Exception): -124 """Raised when integration configuration is invalid""" -125 pass -126 -127class HTTPError(Exception): -128 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" -129 def __init__(self, status: int, message: str, response_data: Any = None): -130 self.status = status -131 """Status code""" -132 self.message = message -133 """Error message""" -134 self.response_data = response_data -135 """Response data""" -136 super().__init__(f"HTTP {status}: {message}") -137 -138class RateLimitError(HTTPError): -139 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). -140 -141 Attributes: -142 retry_after: Seconds to wait before retrying, taken from the -143 ``Retry-After`` response header (defaults to 60 if absent). -144 """ -145 def __init__(self, retry_after: int, *args, **kwargs): -146 self.retry_after = retry_after -147 """Seconds to wait before retrying.""" -148 super().__init__(*args, **kwargs) -149 -150# ---- Result Classes ---- -151@dataclass -152class FetchResponse: -153 """Response object returned by ``ExecutionContext.fetch()``. -154 -155 Wraps the full HTTP response so callers can inspect status codes and -156 headers in addition to the parsed body. -157 -158 Attributes: -159 status: HTTP status code (e.g. ``200``, ``201``). -160 headers: Response headers as a plain ``dict``. -161 data: Parsed JSON (``dict``/``list``) when the response is -162 ``application/json``, otherwise the raw response text. -163 ``None`` for empty 200/201/204 responses. -164 """ -165 status: int -166 headers: Dict[str, str] -167 data: Any -168 -169@dataclass -170class ActionResult: -171 """Result returned by action handlers. -172 -173 This class encapsulates the data returned by an action along with optional -174 billing information for cost tracking. -175 -176 Args: -177 data: The actual result data from the action -178 cost_usd: Optional USD cost for billing purposes -179 -180 Example: -181 ```python -182 return ActionResult( -183 data={"message": "Success", "result": 42}, -184 cost_usd=0.05 -185 ) -186 ``` -187 """ -188 data: Any -189 cost_usd: Optional[float] = None -190 -191@dataclass -192class ActionError: -193 """Error result returned by action handlers for expected/application-level errors. -194 -195 When returned from an action handler, output schema validation is skipped -196 and the error is returned to the caller as a ResultType.ERROR result. -197 -198 Args: -199 message: Human-readable error message -200 cost_usd: Optional USD cost incurred before the error occurred -201 -202 Example: -203 ```python -204 return ActionError( -205 message="User not found", -206 cost_usd=0.01 -207 ) -208 ``` + 59 The platform stores the auth type alongside credentials and passes both + 60 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide + 61 whether to auto-inject an ``Authorization`` header. + 62 + 63 Members: + 64 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the + 65 token lifecycle and injects ``Bearer <access_token>`` automatically. + 66 PlatformTeams: Platform-managed Microsoft Teams auth. + 67 ApiKey: A single API key provided by the user. + 68 Basic: Username/password (HTTP Basic) credentials. + 69 Custom: Free-form credential fields defined by the integration's + 70 ``config.json`` auth schema. The integration is responsible for + 71 reading individual fields from ``context.auth``. + 72 """ + 73 PlatformOauth2 = "PlatformOauth2" + 74 PlatformTeams = "PlatformTeams" + 75 ApiKey = "ApiKey" + 76 Basic = "Basic" + 77 Custom = "Custom" + 78 + 79class ResultType(Enum): + 80 """Type of result being returned""" + 81 ACTION = "action" + 82 ACTION_ERROR = "action_error" + 83 CONNECTED_ACCOUNT = "connected_account" + 84 ERROR = "error" + 85 VALIDATION_ERROR = "validation_error" + 86 + 87# ---- Exceptions ---- + 88class ValidationError(Exception): + 89 """Raised when SDK validation fails. + 90 + 91 This covers several cases: + 92 + 93 - Action inputs don't match the ``input_schema`` in ``config.json`` + 94 - Action outputs don't match the ``output_schema`` + 95 - Auth credentials don't match the ``auth.fields`` schema + 96 - An action handler returns something other than ``ActionResult`` + 97 - A handler name isn't registered + 98 """ + 99 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): +100 self.schema = schema +101 """The schema that failed validation""" +102 self.inputs = inputs +103 """The data that failed validation""" +104 self.message = message +105 """The error message""" +106 self.source = source +107 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" +108 super().__init__(message) +109 +110class ConfigurationError(Exception): +111 """Raised when integration configuration is invalid""" +112 pass +113 +114class HTTPError(Exception): +115 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" +116 def __init__(self, status: int, message: str, response_data: Any = None): +117 self.status = status +118 """Status code""" +119 self.message = message +120 """Error message""" +121 self.response_data = response_data +122 """Response data""" +123 super().__init__(f"HTTP {status}: {message}") +124 +125class RateLimitError(HTTPError): +126 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). +127 +128 Attributes: +129 retry_after: Seconds to wait before retrying, taken from the +130 ``Retry-After`` response header (defaults to 60 if absent). +131 """ +132 def __init__(self, retry_after: int, *args, **kwargs): +133 self.retry_after = retry_after +134 """Seconds to wait before retrying.""" +135 super().__init__(*args, **kwargs) +136 +137# ---- Result Classes ---- +138@dataclass +139class FetchResponse: +140 """Response object returned by ``ExecutionContext.fetch()``. +141 +142 Wraps the full HTTP response so callers can inspect status codes and +143 headers in addition to the parsed body. +144 +145 Attributes: +146 status: HTTP status code (e.g. ``200``, ``201``). +147 headers: Response headers as a plain ``dict``. +148 data: Parsed JSON (``dict``/``list``) when the response is +149 ``application/json``, otherwise the raw response text. +150 ``None`` for empty 200/201/204 responses. +151 """ +152 status: int +153 headers: Dict[str, str] +154 data: Any +155 +156@dataclass +157class ActionResult: +158 """Result returned by action handlers. +159 +160 This class encapsulates the data returned by an action along with optional +161 billing information for cost tracking. +162 +163 Args: +164 data: The actual result data from the action +165 cost_usd: Optional USD cost for billing purposes +166 +167 Example: +168 ```python +169 return ActionResult( +170 data={"message": "Success", "result": 42}, +171 cost_usd=0.05 +172 ) +173 ``` +174 """ +175 data: Any +176 cost_usd: Optional[float] = None +177 +178@dataclass +179class ActionError: +180 """Error result returned by action handlers for expected/application-level errors. +181 +182 When returned from an action handler, output schema validation is skipped +183 and the error is returned to the caller as a ResultType.ERROR result. +184 +185 Args: +186 message: Human-readable error message +187 cost_usd: Optional USD cost incurred before the error occurred +188 +189 Example: +190 ```python +191 return ActionError( +192 message="User not found", +193 cost_usd=0.01 +194 ) +195 ``` +196 """ +197 message: str +198 cost_usd: Optional[float] = None +199 +200@dataclass +201class ConnectedAccountInfo: +202 """Account metadata returned by a ``ConnectedAccountHandler``. +203 +204 The platform calls the connected-account handler after a user links +205 their external account. The returned info is displayed in the +206 Autohive UI (avatar, name, email, etc.). +207 +208 All fields are optional — populate whichever ones the external API provides. 209 """ -210 message: str -211 cost_usd: Optional[float] = None -212 -213@dataclass -214class ConnectedAccountInfo: -215 """Account metadata returned by a ``ConnectedAccountHandler``. -216 -217 The platform calls the connected-account handler after a user links -218 their external account. The returned info is displayed in the -219 Autohive UI (avatar, name, email, etc.). -220 -221 All fields are optional — populate whichever ones the external API provides. -222 """ -223 email: Optional[str] = None -224 first_name: Optional[str] = None -225 last_name: Optional[str] = None -226 username: Optional[str] = None -227 user_id: Optional[str] = None -228 avatar_url: Optional[str] = None -229 organization: Optional[str] = None -230 -231@dataclass -232class IntegrationResult: -233 """Result format sent from lambda wrapper to backend. -234 -235 This class represents the standardized format that the lambda wrapper -236 sends to the Autohive backend, including SDK version and type-specific data. -237 -238 Args: -239 version: SDK version (auto-populated) -240 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) -241 result: The result object - ActionResult for actions, ActionError for -242 application-level action errors, or ConnectedAccountInfo for -243 connected accounts. -244 The lambda wrapper serializes these to dicts using asdict(). -245 -246 Note: -247 This type is returned by Integration methods and serialized by the lambda wrapper. -248 Integration developers should use ActionResult for action handlers and -249 ActionError for expected error conditions. -250 """ -251 version: str -252 type: ResultType -253 result: Union[ActionResult, ActionError, ConnectedAccountInfo] -254 -255# ---- Configuration Classes ---- -256 -257@dataclass -258class Parameter: -259 """Definition of a parameter""" -260 name: str -261 type: str -262 description: str -263 enum: Optional[List[str]] = None -264 required: bool = True -265 default: Any = None +210 email: Optional[str] = None +211 first_name: Optional[str] = None +212 last_name: Optional[str] = None +213 username: Optional[str] = None +214 user_id: Optional[str] = None +215 avatar_url: Optional[str] = None +216 organization: Optional[str] = None +217 +218@dataclass +219class IntegrationResult: +220 """Result format sent from lambda wrapper to backend. +221 +222 This class represents the standardized format that the lambda wrapper +223 sends to the Autohive backend, including SDK version and type-specific data. +224 +225 Args: +226 version: SDK version (auto-populated) +227 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) +228 result: The result object - ActionResult for actions, ActionError for +229 application-level action errors, or ConnectedAccountInfo for +230 connected accounts. +231 The lambda wrapper serializes these to dicts using asdict(). +232 +233 Note: +234 This type is returned by Integration methods and serialized by the lambda wrapper. +235 Integration developers should use ActionResult for action handlers and +236 ActionError for expected error conditions. +237 """ +238 version: str +239 type: ResultType +240 result: Union[ActionResult, ActionError, ConnectedAccountInfo] +241 +242# ---- Configuration Classes ---- +243 +244@dataclass +245class Parameter: +246 """Definition of a parameter""" +247 name: str +248 type: str +249 description: str +250 enum: Optional[List[str]] = None +251 required: bool = True +252 default: Any = None +253 +254@dataclass +255class SchemaDefinition: +256 """Base class for components that have input/output schemas""" +257 name: str +258 description: str +259 input_schema: List[Parameter] +260 output_schema: Optional[Dict[str, Any]] = None +261 +262@dataclass +263class Action(SchemaDefinition): +264 """Empty dataclass that inherits from SchemaDefinition""" +265 pass 266 267@dataclass -268class SchemaDefinition: -269 """Base class for components that have input/output schemas""" -270 name: str -271 description: str -272 input_schema: List[Parameter] -273 output_schema: Optional[Dict[str, Any]] = None -274 -275@dataclass -276class Action(SchemaDefinition): -277 """Empty dataclass that inherits from SchemaDefinition""" -278 pass -279 -280@dataclass -281class PollingTrigger(SchemaDefinition): -282 """Definition of a polling trigger""" -283 polling_interval: timedelta = field(default_factory=timedelta) -284 -285@dataclass -286class IntegrationConfig: -287 """Configuration for an integration""" -288 name: str -289 version: str -290 description: str -291 auth: Dict[str, Any] -292 actions: Dict[str, Action] -293 polling_triggers: Dict[str, PollingTrigger] -294 -295# ---- Base Handler Classes ---- -296class ActionHandler(ABC): -297 """Base class for action handlers. -298 -299 Subclass this and implement ``execute()`` to handle a specific action. -300 Register it with the ``@integration.action("action_name")`` decorator. -301 -302 Example:: -303 -304 @integration.action("get_user") -305 class GetUser(ActionHandler): -306 async def execute(self, inputs, context): -307 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data -308 return ActionResult(data=user) -309 """ -310 @abstractmethod -311 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: -312 """Run the action logic. -313 -314 Args: -315 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. -316 context: Execution context providing ``fetch()``, ``auth``, and logging. -317 -318 Returns: -319 An ``ActionResult`` containing the output data and optional ``cost_usd``. -320 """ -321 pass +268class PollingTrigger(SchemaDefinition): +269 """Definition of a polling trigger""" +270 polling_interval: timedelta = field(default_factory=timedelta) +271 +272@dataclass +273class IntegrationConfig: +274 """Configuration for an integration""" +275 name: str +276 version: str +277 description: str +278 auth: Dict[str, Any] +279 actions: Dict[str, Action] +280 polling_triggers: Dict[str, PollingTrigger] +281 +282# ---- Base Handler Classes ---- +283class ActionHandler(ABC): +284 """Base class for action handlers. +285 +286 Subclass this and implement ``execute()`` to handle a specific action. +287 Register it with the ``@integration.action("action_name")`` decorator. +288 +289 Example:: +290 +291 @integration.action("get_user") +292 class GetUser(ActionHandler): +293 async def execute(self, inputs, context): +294 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data +295 return ActionResult(data=user) +296 """ +297 @abstractmethod +298 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: +299 """Run the action logic. +300 +301 Args: +302 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. +303 context: Execution context providing ``fetch()``, ``auth``, and logging. +304 +305 Returns: +306 An ``ActionResult`` containing the output data and optional ``cost_usd``. +307 """ +308 pass +309 +310class PollingTriggerHandler(ABC): +311 """Base class for polling trigger handlers""" +312 @abstractmethod +313 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: +314 """Execute the polling trigger""" +315 pass +316 +317class ConnectedAccountHandler(ABC): +318 """Base class for connected-account handlers. +319 +320 The platform calls this after a user links their external account. +321 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. 322 -323class PollingTriggerHandler(ABC): -324 """Base class for polling trigger handlers""" -325 @abstractmethod -326 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: -327 """Execute the polling trigger""" -328 pass -329 -330class ConnectedAccountHandler(ABC): -331 """Base class for connected-account handlers. -332 -333 The platform calls this after a user links their external account. -334 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. -335 -336 Register with the ``@integration.connected_account()`` decorator. -337 -338 Example:: -339 -340 @integration.connected_account() -341 class MyAccountHandler(ConnectedAccountHandler): -342 async def get_account_info(self, context): -343 me = (await context.fetch("https://api.example.com/me")).data -344 return ConnectedAccountInfo( -345 email=me["email"], -346 first_name=me["first_name"], -347 last_name=me["last_name"], -348 ) -349 """ -350 @abstractmethod -351 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: -352 """Fetch account metadata from the external service. -353 -354 For platform OAuth integrations, ``context.fetch()`` auto-injects -355 the Bearer token — no manual auth handling needed. -356 -357 Returns: -358 A ``ConnectedAccountInfo`` with whichever fields the API provides. -359 """ -360 pass -361 -362# ---- Core SDK Classes ---- -363class ExecutionContext: -364 """Context provided to integration handlers for making authenticated HTTP requests. -365 -366 Manages an ``aiohttp`` session with automatic retries, error handling, -367 default ``User-Agent`` handling, and optional Bearer-token injection for -368 platform OAuth integrations. -369 -370 Use as an async context manager:: -371 -372 async with ExecutionContext(auth=auth) as context: -373 result = await integration.execute_action("my_action", inputs, context) -374 -375 Args: -376 auth: Authentication data. In **local tests** this is a flat dict -377 matching the ``auth.fields`` schema in ``config.json`` -378 (e.g. ``{"api_key": "..."}``). In **production** the platform -379 wraps credentials as ``{"auth_type": "...", "credentials": {...}}``. -380 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). -381 metadata: Arbitrary metadata forwarded to handlers. -382 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. -383 """ -384 def __init__( -385 self, -386 auth: Dict[str, Any] = {}, -387 request_config: Optional[Dict[str, Any]] = None, -388 metadata: Optional[Dict[str, Any]] = None, -389 logger: Optional[logging.Logger] = None -390 ): -391 self.auth = auth -392 """Authentication configuration""" -393 self.config = request_config or {"max_retries": 3, "timeout": 30} -394 """Request configuration""" -395 self.metadata = metadata or {} -396 """Additional metadata""" -397 self.logger = logger or logging.getLogger(__name__) -398 """Logger instance""" -399 self._session: Optional[aiohttp.ClientSession] = None -400 self._integration_name: Optional[str] = None -401 self._integration_version: Optional[str] = None -402 -403 async def __aenter__(self): -404 if not self._session: -405 self._session = aiohttp.ClientSession() -406 return self -407 -408 async def __aexit__(self, exc_type, exc_val, exc_tb): -409 if self._session: -410 await self._session.close() -411 self._session = None -412 -413 def _set_integration_identity(self, name: Optional[str], version: Optional[str]) -> None: -414 """Attach integration identity for SDK-generated request metadata.""" -415 self._integration_name = name -416 self._integration_version = version -417 -418 def _build_default_user_agent(self) -> str: -419 if self._integration_name and self._integration_version: -420 integration_token = ( -421 f"{_sanitize_user_agent_token(self._integration_name)}/" -422 f"{_sanitize_user_agent_token(self._integration_version)}" -423 ) -424 return f"{DEFAULT_USER_AGENT} {integration_token}" -425 -426 return DEFAULT_USER_AGENT -427 -428 async def fetch( -429 self, -430 url: str, -431 method: str = "GET", -432 params: Optional[Dict[str, Any]] = None, -433 data: Any = None, -434 json: Any = None, -435 headers: Optional[Dict[str, str]] = None, -436 content_type: Optional[str] = None, -437 timeout: Optional[int] = None, -438 retry_count: int = 0, -439 user_agent: Optional[str] = None -440 ) -> FetchResponse: -441 """Make an HTTP request with automatic retries and error handling. -442 -443 If no ``User-Agent`` header is provided, a default SDK ``User-Agent`` is -444 added. When the request is made inside a handler executed by -445 ``Integration``, the integration's ``config.json`` name and version are -446 included. Pass ``user_agent`` to set a per-request value more easily. -447 Explicit ``User-Agent`` headers always take precedence. +323 Register with the ``@integration.connected_account()`` decorator. +324 +325 Example:: +326 +327 @integration.connected_account() +328 class MyAccountHandler(ConnectedAccountHandler): +329 async def get_account_info(self, context): +330 me = (await context.fetch("https://api.example.com/me")).data +331 return ConnectedAccountInfo( +332 email=me["email"], +333 first_name=me["first_name"], +334 last_name=me["last_name"], +335 ) +336 """ +337 @abstractmethod +338 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: +339 """Fetch account metadata from the external service. +340 +341 For platform OAuth integrations, ``context.fetch()`` auto-injects +342 the Bearer token — no manual auth handling needed. +343 +344 Returns: +345 A ``ConnectedAccountInfo`` with whichever fields the API provides. +346 """ +347 pass +348 +349# ---- Core SDK Classes ---- +350class ExecutionContext: +351 """Context provided to integration handlers for making authenticated HTTP requests. +352 +353 Manages an ``aiohttp`` session with automatic retries, error handling, and +354 optional Bearer-token injection for platform OAuth integrations. +355 +356 Use as an async context manager:: +357 +358 async with ExecutionContext(auth=auth) as context: +359 result = await integration.execute_action("my_action", inputs, context) +360 +361 Args: +362 auth: Authentication data. This is always the platform auth envelope +363 ``{"auth_type": "...", "credentials": {...}}``. The ``credentials`` +364 object matches the ``auth.fields`` schema in ``config.json``. Local +365 tests must use the same wrapped shape (e.g. +366 ``{"auth_type": "Custom", "credentials": {"api_key": "..."}}``); flat +367 auth is not supported. Handlers should read +368 individual credentials via ``context.auth["credentials"]`` (for +369 example ``context.auth["credentials"].get("api_key", "")``); strict +370 validation guarantees this shape exists before a handler runs. +371 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). +372 metadata: Arbitrary metadata forwarded to handlers. +373 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. +374 """ +375 def __init__( +376 self, +377 auth: Dict[str, Any] = {}, +378 request_config: Optional[Dict[str, Any]] = None, +379 metadata: Optional[Dict[str, Any]] = None, +380 logger: Optional[logging.Logger] = None +381 ): +382 self.auth = auth +383 """Authentication configuration""" +384 self.config = request_config or {"max_retries": 3, "timeout": 30} +385 """Request configuration""" +386 self.metadata = metadata or {} +387 """Additional metadata""" +388 self.logger = logger or logging.getLogger(__name__) +389 """Logger instance""" +390 self._session: Optional[aiohttp.ClientSession] = None +391 +392 async def __aenter__(self): +393 if not self._session: +394 self._session = aiohttp.ClientSession() +395 return self +396 +397 async def __aexit__(self, exc_type, exc_val, exc_tb): +398 if self._session: +399 await self._session.close() +400 self._session = None +401 +402 async def fetch( +403 self, +404 url: str, +405 method: str = "GET", +406 params: Optional[Dict[str, Any]] = None, +407 data: Any = None, +408 json: Any = None, +409 headers: Optional[Dict[str, str]] = None, +410 content_type: Optional[str] = None, +411 timeout: Optional[int] = None, +412 retry_count: int = 0 +413 ) -> FetchResponse: +414 """Make an HTTP request with automatic retries and error handling. +415 +416 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), +417 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` +418 unless an ``Authorization`` header is explicitly provided. +419 +420 Retries up to ``max_retries`` (default 3) on transient network errors +421 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` +422 immediately (no automatic retry). +423 +424 Args: +425 url: The URL to request. +426 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). +427 params: Query parameters appended to the URL. Nested dicts/lists +428 are JSON-serialized automatically. +429 data: Raw request body. Encoding depends on ``content_type``. +430 json: JSON-serializable payload. Sets ``content_type`` to +431 ``application/json`` automatically. +432 headers: Additional HTTP headers. Merged *after* any auto-injected +433 auth header, so an explicit ``Authorization`` takes precedence. +434 content_type: ``Content-Type`` header value. +435 timeout: Per-request timeout in seconds (overrides ``request_config``). +436 retry_count: Internal — current retry attempt number. +437 +438 Returns: +439 A ``FetchResponse`` containing the HTTP status code, response +440 headers, and parsed body data. +441 +442 Raises: +443 RateLimitError: On HTTP 429 with the ``Retry-After`` value. +444 HTTPError: On any other non-2xx status. +445 """ +446 if not self._session: +447 self._session = aiohttp.ClientSession() 448 -449 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), -450 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` -451 unless an ``Authorization`` header is explicitly provided. -452 -453 Retries up to ``max_retries`` (default 3) on transient network errors -454 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` -455 immediately (no automatic retry). -456 -457 Args: -458 url: The URL to request. -459 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). -460 params: Query parameters appended to the URL. Nested dicts/lists -461 are JSON-serialized automatically. -462 data: Raw request body. Encoding depends on ``content_type``. -463 json: JSON-serializable payload. Sets ``content_type`` to -464 ``application/json`` automatically. -465 headers: Additional HTTP headers. Merged *after* any auto-injected -466 auth header, so explicit ``Authorization`` and ``User-Agent`` -467 values take precedence. -468 user_agent: Convenience override for the request ``User-Agent``. -469 Ignored when ``headers`` already contains a ``User-Agent`` key. -470 content_type: ``Content-Type`` header value. -471 timeout: Per-request timeout in seconds (overrides ``request_config``). -472 retry_count: Internal — current retry attempt number. -473 -474 Returns: -475 A ``FetchResponse`` containing the HTTP status code, response -476 headers, and parsed body data. -477 -478 Raises: -479 RateLimitError: On HTTP 429 with the ``Retry-After`` value. -480 HTTPError: On any other non-2xx status. -481 """ -482 if not self._session: -483 self._session = aiohttp.ClientSession() -484 -485 # Prepare request -486 if json is not None: -487 data = json -488 content_type = "application/json" -489 -490 final_headers = {} +449 # Prepare request +450 if json is not None: +451 data = json +452 content_type = "application/json" +453 +454 final_headers = {} +455 +456 if self.auth and "Authorization" not in (headers or {}): +457 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) +458 credentials = self.auth.get("credentials", {}) +459 +460 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: +461 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" +462 +463 if content_type: +464 final_headers["Content-Type"] = content_type +465 if headers: +466 final_headers.update(headers) +467 +468 if params: +469 # Handle nested dictionary parameters +470 flat_params = {} +471 for key, value in params.items(): +472 if isinstance(value, (dict, list)): +473 flat_params[key] = jsonX.dumps(value) +474 elif value is not None: +475 flat_params[key] = str(value) +476 query_string = urlencode(flat_params) +477 url = f"{url}{'&' if '?' in url else '?'}{query_string}" +478 +479 # Prepare body +480 if data is not None: +481 if content_type == "application/json": +482 data = jsonX.dumps(data) +483 elif content_type == "application/x-www-form-urlencoded": +484 data = urlencode(data) if isinstance(data, dict) else data +485 +486 # Store the original timeout numeric value +487 original_timeout = timeout or self.config["timeout"] +488 +489 # Convert the numeric timeout to a ClientTimeout instance for this request +490 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 491 -492 if not any(key.lower() == "user-agent" for key in (headers or {})): -493 final_headers["User-Agent"] = user_agent or self._build_default_user_agent() -494 -495 if self.auth and "Authorization" not in (headers or {}): -496 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) -497 credentials = self.auth.get("credentials", {}) -498 -499 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: -500 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" -501 -502 if content_type: -503 final_headers["Content-Type"] = content_type -504 if headers: -505 final_headers.update(headers) -506 -507 if params: -508 # Handle nested dictionary parameters -509 flat_params = {} -510 for key, value in params.items(): -511 if isinstance(value, (dict, list)): -512 flat_params[key] = jsonX.dumps(value) -513 elif value is not None: -514 flat_params[key] = str(value) -515 query_string = urlencode(flat_params) -516 url = f"{url}{'&' if '?' in url else '?'}{query_string}" -517 -518 # Prepare body -519 if data is not None: -520 if content_type == "application/json": -521 data = jsonX.dumps(data) -522 elif content_type == "application/x-www-form-urlencoded": -523 data = urlencode(data) if isinstance(data, dict) else data +492 try: +493 async with self._session.request( +494 method=method, +495 url=url, +496 data=data, +497 headers=final_headers, +498 timeout=client_timeout, +499 ssl=True +500 ) as response: +501 content_type = response.headers.get("Content-Type", "") +502 +503 if response.status == 429: # Rate limit +504 retry_after = int(response.headers.get("Retry-After", 60)) +505 raise RateLimitError( +506 retry_after, +507 response.status, +508 "Rate limit exceeded", +509 await response.text() +510 ) +511 +512 try: +513 if "application/json" in content_type: +514 result = await response.json() +515 else: +516 result = await response.text() +517 if not result and response.status in {200, 201, 204}: +518 result = None +519 except Exception as e: +520 self.logger.error(f"Error parsing response: {e}") +521 result = await response.text() +522 +523 response_headers = dict(response.headers) 524 -525 # Store the original timeout numeric value -526 original_timeout = timeout or self.config["timeout"] -527 -528 # Convert the numeric timeout to a ClientTimeout instance for this request -529 client_timeout = aiohttp.ClientTimeout(total=original_timeout) -530 -531 try: -532 async with self._session.request( -533 method=method, -534 url=url, -535 data=data, -536 headers=final_headers, -537 timeout=client_timeout, -538 ssl=True -539 ) as response: -540 content_type = response.headers.get("Content-Type", "") -541 -542 if response.status == 429: # Rate limit -543 retry_after = int(response.headers.get("Retry-After", 60)) -544 raise RateLimitError( -545 retry_after, -546 response.status, -547 "Rate limit exceeded", -548 await response.text() -549 ) -550 -551 try: -552 if "application/json" in content_type: -553 result = await response.json() -554 else: -555 result = await response.text() -556 if not result and response.status in {200, 201, 204}: -557 result = None -558 except Exception as e: -559 self.logger.error(f"Error parsing response: {e}") -560 result = await response.text() -561 -562 response_headers = dict(response.headers) -563 -564 if not response.ok: -565 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") -566 raise HTTPError(response.status, str(result), result) -567 -568 return FetchResponse( -569 status=response.status, -570 headers=response_headers, -571 data=result, -572 ) -573 -574 except RateLimitError: -575 raise -576 except (aiohttp.ClientError, asyncio.TimeoutError) as e: -577 # Don't want to send this to Raygun here because this will be retried. -578 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") -579 if retry_count < self.config["max_retries"]: -580 await asyncio.sleep(2 ** retry_count) # Exponential backoff -581 print("Retrying request...") -582 # Use original_timeout (numeric) for recursive calls -583 return await self.fetch( -584 url, method, params, data, json, -585 headers, content_type, original_timeout, retry_count + 1, -586 user_agent=user_agent, -587 ) -588 else: -589 print("Max retries reached. Raising error.") -590 raise -591 except Exception as e: -592 self.logger.error(f"Unexpected error during {method} {url}: {e}") -593 print(f"Unexpected error encountered: {e}") -594 raise -595 -596 -597class Integration: -598 """Base integration class with handler registration and execution. -599 -600 This class manages the integration configuration, handler registration, -601 and provides methods to execute actions and triggers. -602 -603 Args: -604 config: Integration configuration -605 -606 Attributes: -607 config: Integration configuration -608 """ -609 -610 def __init__(self, config: IntegrationConfig): -611 self.config = config -612 """Integration configuration""" -613 self._action_handlers: Dict[str, Type[ActionHandler]] = {} -614 """Action handlers""" -615 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} -616 """Polling handlers""" -617 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None -618 """Connected account handler""" -619 -620 @classmethod -621 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': -622 """Load an integration from its ``config.json``. -623 -624 Args: -625 config_path: Explicit path to ``config.json``. When omitted the -626 SDK resolves the path relative to its own package location, -627 which works when the SDK is vendored via -628 ``pip install --target dependencies``. Multi-file integrations -629 that use ``actions/`` sub-packages should pass an explicit path -630 (e.g. ``Integration.load("config.json")``). -631 -632 Returns: -633 A fully initialised ``Integration`` ready for handler registration. -634 -635 Raises: -636 ConfigurationError: If the file is missing or contains invalid JSON. -637 """ -638 if config_path is None: -639 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') -640 -641 config_path = Path(config_path) -642 -643 if not config_path.exists(): -644 raise ConfigurationError(f"Configuration file not found: {config_path}") -645 -646 try: -647 with open(config_path, 'r') as f: -648 config_data = json.load(f) -649 except json.JSONDecodeError as e: -650 raise ConfigurationError(f"Invalid JSON configuration: {e}") -651 -652 # Parse configuration sections -653 actions = cls._parse_actions(config_data.get("actions", {})) -654 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) +525 if not response.ok: +526 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") +527 raise HTTPError(response.status, str(result), result) +528 +529 return FetchResponse( +530 status=response.status, +531 headers=response_headers, +532 data=result, +533 ) +534 +535 except RateLimitError: +536 raise +537 except (aiohttp.ClientError, asyncio.TimeoutError) as e: +538 # Don't want to send this to Raygun here because this will be retried. +539 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") +540 if retry_count < self.config["max_retries"]: +541 await asyncio.sleep(2 ** retry_count) # Exponential backoff +542 print("Retrying request...") +543 # Use original_timeout (numeric) for recursive calls +544 return await self.fetch( +545 url, method, params, data, json, +546 headers, content_type, original_timeout, retry_count + 1 +547 ) +548 else: +549 print("Max retries reached. Raising error.") +550 raise +551 except Exception as e: +552 self.logger.error(f"Unexpected error during {method} {url}: {e}") +553 print(f"Unexpected error encountered: {e}") +554 raise +555 +556 +557class Integration: +558 """Base integration class with handler registration and execution. +559 +560 This class manages the integration configuration, handler registration, +561 and provides methods to execute actions and triggers. +562 +563 Args: +564 config: Integration configuration +565 +566 Attributes: +567 config: Integration configuration +568 """ +569 +570 def __init__(self, config: IntegrationConfig): +571 self.config = config +572 """Integration configuration""" +573 self._action_handlers: Dict[str, Type[ActionHandler]] = {} +574 """Action handlers""" +575 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} +576 """Polling handlers""" +577 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None +578 """Connected account handler""" +579 +580 @classmethod +581 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': +582 """Load an integration from its ``config.json``. +583 +584 Args: +585 config_path: Explicit path to ``config.json``. When omitted the +586 SDK resolves the path relative to its own package location, +587 which works when the SDK is vendored via +588 ``pip install --target dependencies``. Multi-file integrations +589 that use ``actions/`` sub-packages should pass an explicit path +590 (e.g. ``Integration.load("config.json")``). +591 +592 Returns: +593 A fully initialised ``Integration`` ready for handler registration. +594 +595 Raises: +596 ConfigurationError: If the file is missing or contains invalid JSON. +597 """ +598 if config_path is None: +599 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') +600 +601 config_path = Path(config_path) +602 +603 if not config_path.exists(): +604 raise ConfigurationError(f"Configuration file not found: {config_path}") +605 +606 try: +607 with open(config_path, 'r') as f: +608 config_data = json.load(f) +609 except json.JSONDecodeError as e: +610 raise ConfigurationError(f"Invalid JSON configuration: {e}") +611 +612 # Parse configuration sections +613 actions = cls._parse_actions(config_data.get("actions", {})) +614 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) +615 +616 config = IntegrationConfig( +617 name=config_data["name"], +618 version=config_data["version"], +619 description=config_data["description"], +620 auth=config_data.get("auth", {}), +621 actions=actions, +622 polling_triggers=polling_triggers +623 ) +624 +625 return cls(config) +626 +627 @staticmethod +628 def _parse_interval(interval_str: str) -> timedelta: +629 """Parse interval string into timedelta""" +630 unit = interval_str[-1].lower() +631 value = int(interval_str[:-1]) +632 +633 if unit == 's': +634 return timedelta(seconds=value) +635 elif unit == 'm': +636 return timedelta(minutes=value) +637 elif unit == 'h': +638 return timedelta(hours=value) +639 elif unit == 'd': +640 return timedelta(days=value) +641 else: +642 raise ConfigurationError(f"Invalid interval format: {interval_str}") +643 +644 @classmethod +645 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: +646 """Parse action configurations""" +647 actions = {} +648 for name, data in actions_config.items(): +649 actions[name] = Action( +650 name=name, +651 description=data["description"], +652 input_schema=data["input_schema"], +653 output_schema=data["output_schema"] +654 ) 655 -656 config = IntegrationConfig( -657 name=config_data["name"], -658 version=config_data["version"], -659 description=config_data["description"], -660 auth=config_data.get("auth", {}), -661 actions=actions, -662 polling_triggers=polling_triggers -663 ) +656 return actions +657 +658 @classmethod +659 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: +660 """Parse polling trigger configurations""" +661 triggers = {} +662 for name, data in triggers_config.items(): +663 interval = cls._parse_interval(data["polling_interval"]) 664 -665 return cls(config) -666 -667 @staticmethod -668 def _parse_interval(interval_str: str) -> timedelta: -669 """Parse interval string into timedelta""" -670 unit = interval_str[-1].lower() -671 value = int(interval_str[:-1]) +665 triggers[name] = PollingTrigger( +666 name=name, +667 description=data["description"], +668 polling_interval=interval, +669 input_schema=data["input_schema"], +670 output_schema=data["output_schema"] +671 ) 672 -673 if unit == 's': -674 return timedelta(seconds=value) -675 elif unit == 'm': -676 return timedelta(minutes=value) -677 elif unit == 'h': -678 return timedelta(hours=value) -679 elif unit == 'd': -680 return timedelta(days=value) -681 else: -682 raise ConfigurationError(f"Invalid interval format: {interval_str}") -683 -684 @classmethod -685 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: -686 """Parse action configurations""" -687 actions = {} -688 for name, data in actions_config.items(): -689 actions[name] = Action( -690 name=name, -691 description=data["description"], -692 input_schema=data["input_schema"], -693 output_schema=data["output_schema"] -694 ) -695 -696 return actions -697 -698 @classmethod -699 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: -700 """Parse polling trigger configurations""" -701 triggers = {} -702 for name, data in triggers_config.items(): -703 interval = cls._parse_interval(data["polling_interval"]) -704 -705 triggers[name] = PollingTrigger( -706 name=name, -707 description=data["description"], -708 polling_interval=interval, -709 input_schema=data["input_schema"], -710 output_schema=data["output_schema"] -711 ) -712 -713 return triggers +673 return triggers +674 +675 def action(self, name: str): +676 """Decorator to register an action handler. +677 +678 Args: +679 name: Name of the action to register +680 +681 Returns: +682 Decorator function +683 +684 Raises: +685 ConfigurationError: If action is not defined in config +686 +687 Example: +688 ```python +689 @integration.action("my_action") +690 class MyActionHandler(ActionHandler): +691 async def execute(self, inputs, context): +692 # Implementation +693 return result +694 ``` +695 """ +696 def decorator(handler_class: Type[ActionHandler]): +697 if name not in self.config.actions: +698 raise ConfigurationError(f"Action '{name}' not defined in config") +699 self._action_handlers[name] = handler_class +700 return handler_class +701 return decorator +702 +703 def polling_trigger(self, name: str): +704 """Decorator to register a polling trigger handler +705 +706 Args: +707 name: Name of the polling trigger to register +708 +709 Returns: +710 Decorator function +711 +712 Raises: +713 ConfigurationError: If polling trigger is not defined in config 714 -715 def action(self, name: str): -716 """Decorator to register an action handler. -717 -718 Args: -719 name: Name of the action to register -720 -721 Returns: -722 Decorator function -723 -724 Raises: -725 ConfigurationError: If action is not defined in config -726 -727 Example: -728 ```python -729 @integration.action("my_action") -730 class MyActionHandler(ActionHandler): -731 async def execute(self, inputs, context): -732 # Implementation -733 return result -734 ``` -735 """ -736 def decorator(handler_class: Type[ActionHandler]): -737 if name not in self.config.actions: -738 raise ConfigurationError(f"Action '{name}' not defined in config") -739 self._action_handlers[name] = handler_class -740 return handler_class -741 return decorator -742 -743 def polling_trigger(self, name: str): -744 """Decorator to register a polling trigger handler -745 -746 Args: -747 name: Name of the polling trigger to register -748 -749 Returns: -750 Decorator function -751 -752 Raises: -753 ConfigurationError: If polling trigger is not defined in config -754 -755 Example: -756 ```python -757 @integration.polling_trigger("my_polling_trigger") -758 class MyPollingTriggerHandler(PollingTriggerHandler): -759 async def poll(self, inputs, last_poll_ts, context): -760 # Implementation -761 return result -762 ``` -763 """ -764 def decorator(handler_class: Type[PollingTriggerHandler]): -765 if name not in self.config.polling_triggers: -766 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") -767 self._polling_handlers[name] = handler_class -768 return handler_class -769 return decorator +715 Example: +716 ```python +717 @integration.polling_trigger("my_polling_trigger") +718 class MyPollingTriggerHandler(PollingTriggerHandler): +719 async def poll(self, inputs, last_poll_ts, context): +720 # Implementation +721 return result +722 ``` +723 """ +724 def decorator(handler_class: Type[PollingTriggerHandler]): +725 if name not in self.config.polling_triggers: +726 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") +727 self._polling_handlers[name] = handler_class +728 return handler_class +729 return decorator +730 +731 def connected_account(self): +732 """Decorator to register a connected account handler +733 +734 Returns: +735 Decorator function +736 +737 Example: +738 ```python +739 @integration.connected_account() +740 class MyConnectedAccountHandler(ConnectedAccountHandler): +741 async def get_account_info(self, context): +742 # Implementation +743 return {"email": "user@example.com", "name": "John Doe"} +744 ``` +745 """ +746 def decorator(handler_class: Type[ConnectedAccountHandler]): +747 self._connected_account_handler = handler_class +748 return handler_class +749 return decorator +750 +751 def _validate_auth(self, context: ExecutionContext) -> None: +752 """Validate the auth envelope's credentials against ``auth.fields``. +753 +754 The platform always passes ``context.auth`` as the +755 wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The +756 ``auth.fields`` schema in ``config.json`` describes only the inner +757 ``credentials`` object, so validation runs against +758 ``context.auth["credentials"]`` — not the whole envelope. +759 +760 Integrations with no auth or no ``fields`` key skip validation entirely. +761 +762 Raises: +763 ValidationError: (source ``"auth"``) if ``context.auth`` is not a +764 wrapped envelope (a dict with a non-empty string ``auth_type`` +765 and a dict ``credentials``), or if the credentials fail the +766 schema. +767 """ +768 if "fields" not in self.config.auth: +769 return 770 -771 def connected_account(self): -772 """Decorator to register a connected account handler -773 -774 Returns: -775 Decorator function -776 -777 Example: -778 ```python -779 @integration.connected_account() -780 class MyConnectedAccountHandler(ConnectedAccountHandler): -781 async def get_account_info(self, context): -782 # Implementation -783 return {"email": "user@example.com", "name": "John Doe"} -784 ``` -785 """ -786 def decorator(handler_class: Type[ConnectedAccountHandler]): -787 self._connected_account_handler = handler_class -788 return handler_class -789 return decorator -790 -791 async def execute_action(self, -792 name: str, -793 inputs: Dict[str, Any], -794 context: ExecutionContext) -> IntegrationResult: -795 """Execute a registered action. -796 -797 Args: -798 name: Name of the action to execute -799 inputs: Action inputs -800 context: Execution context -801 -802 Returns: -803 IntegrationResult with action data (ResultType.ACTION), -804 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, -805 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. -806 """ -807 try: -808 if name not in self._action_handlers: -809 raise ValidationError(f"Action '{name}' not registered") -810 -811 # Validate inputs against action schema -812 action_config = self.config.actions[name] -813 validator = Draft7Validator(action_config.input_schema) -814 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) -815 if errors: -816 message = "" -817 for error in errors: -818 message += f"{list(error.schema_path)}, {error.message},\n " -819 raise ValidationError(message, action_config.input_schema, inputs, source="input") -820 -821 if "fields" in self.config.auth: -822 auth_config = self.config.auth["fields"] -823 validator = Draft7Validator(auth_config) -824 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -825 if errors: -826 message = "" -827 for error in errors: -828 message += f"{list(error.schema_path)}, {error.message},\n " -829 raise ValidationError(message, auth_config, context.auth, source="input") -830 -831 # Create handler instance and execute -832 handler = self._action_handlers[name]() -833 previous_identity = (context._integration_name, context._integration_version) -834 context._set_integration_identity(self.config.name, self.config.version) -835 try: -836 result = await handler.execute(inputs, context) -837 finally: -838 context._set_integration_identity(*previous_identity) -839 -840 # Handle ActionError - skip output schema validation -841 if isinstance(result, ActionError): -842 return IntegrationResult( -843 version=__version__, -844 type=ResultType.ACTION_ERROR, -845 result=result -846 ) -847 -848 # Validate that result is ActionResult -849 if not isinstance(result, ActionResult): -850 raise ValidationError( -851 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", -852 source="output" -853 ) -854 -855 # Validate output schema against the data inside ActionResult -856 validator = Draft7Validator(action_config.output_schema) -857 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) -858 if errors: -859 message = "" -860 for error in errors: -861 message += f"{list(error.schema_path)}, {error.message},\n " -862 raise ValidationError(message, action_config.output_schema, result.data, source="output") -863 -864 # Return IntegrationResult with ActionResult directly -865 return IntegrationResult( -866 version=__version__, -867 type=ResultType.ACTION, -868 result=result -869 ) -870 except ValidationError as e: -871 return IntegrationResult( -872 version=__version__, -873 type=ResultType.VALIDATION_ERROR, -874 result={ -875 'message': str(e), -876 'property': None, -877 'value': None, -878 'source': getattr(e, 'source', 'legacy') -879 } -880 ) -881 -882 async def execute_polling_trigger(self, -883 name: str, -884 inputs: Dict[str, Any], -885 last_poll_ts: Optional[str], -886 context: ExecutionContext) -> List[Dict[str, Any]]: -887 """Execute a registered polling trigger -888 -889 Args: -890 name: Name of the polling trigger to execute -891 inputs: Trigger inputs -892 last_poll_ts: Last poll timestamp -893 context: Execution context -894 -895 Returns: -896 List of records -897 -898 Raises: -899 ValidationError: If inputs or outputs don't match schema -900 """ -901 if name not in self._polling_handlers: -902 raise ValidationError(f"Polling trigger '{name}' not registered") -903 -904 # Validate trigger configuration -905 trigger_config = self.config.polling_triggers[name] -906 try: -907 validate(inputs, trigger_config.input_schema) -908 except Exception as e: -909 raise ValidationError(e.message, e.schema, e.instance) -910 -911 try: -912 auth_config = self.config.auth["fields"] -913 validate(context.auth, auth_config) -914 except Exception as e: -915 raise ValidationError(e.message, e.schema, e.instance) -916 -917 # Create handler instance and execute -918 handler = self._polling_handlers[name]() -919 previous_identity = (context._integration_name, context._integration_version) -920 context._set_integration_identity(self.config.name, self.config.version) -921 try: -922 records = await handler.poll(inputs, last_poll_ts, context) -923 finally: -924 context._set_integration_identity(*previous_identity) -925 # Validate each record -926 for record in records: -927 if "id" not in record: -928 raise ValidationError( -929 f"Polling trigger '{name}' returned record without required 'id' field") -930 if "data" not in record: -931 raise ValidationError( -932 f"Polling trigger '{name}' returned record without required 'data' field") -933 -934 # Validate record data against output schema -935 try: -936 validate(record["data"], trigger_config.output_schema) -937 except Exception as e: -938 raise ValidationError(e.message, e.schema, e.instance) -939 -940 return records -941 -942 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: -943 """Get connected account information -944 -945 Args: -946 context: Execution context -947 -948 Returns: -949 IntegrationResult containing connected account data -950 -951 Raises: -952 ValidationError: If no connected account handler is registered or auth is invalid -953 """ -954 if not self._connected_account_handler: -955 raise ValidationError("No connected account handler registered") +771 auth = context.auth +772 has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict) +773 has_valid_auth_type = ( +774 isinstance(auth, dict) +775 and isinstance(auth.get("auth_type"), str) +776 and auth.get("auth_type") != "" +777 ) +778 if not has_valid_credentials or not has_valid_auth_type: +779 raise ValidationError( +780 'context.auth must be the platform auth envelope ' +781 '{"auth_type": ..., "credentials": {...}} with a non-empty ' +782 'auth_type; flat auth is not supported.', +783 source="auth", +784 ) +785 +786 valid_auth_types = {member.value for member in AuthType} +787 if auth["auth_type"] not in valid_auth_types: +788 raise ValidationError( +789 f'Unknown auth_type "{auth["auth_type"]}" in context.auth; ' +790 f'expected one of: {", ".join(sorted(valid_auth_types))}.', +791 source="auth", +792 ) +793 +794 auth_config = self.config.auth["fields"] +795 validator = Draft7Validator(auth_config) +796 errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path) +797 if errors: +798 message = "" +799 for error in errors: +800 message += f"{list(error.schema_path)}, {error.message},\n " +801 raise ValidationError(message, auth_config, context.auth["credentials"], source="auth") +802 +803 async def execute_action(self, +804 name: str, +805 inputs: Dict[str, Any], +806 context: ExecutionContext) -> IntegrationResult: +807 """Execute a registered action. +808 +809 Args: +810 name: Name of the action to execute +811 inputs: Action inputs +812 context: Execution context +813 +814 Returns: +815 IntegrationResult with action data (ResultType.ACTION), +816 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, +817 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. +818 """ +819 try: +820 if name not in self._action_handlers: +821 raise ValidationError(f"Action '{name}' not registered") +822 +823 # Validate inputs against action schema +824 action_config = self.config.actions[name] +825 validator = Draft7Validator(action_config.input_schema) +826 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) +827 if errors: +828 message = "" +829 for error in errors: +830 message += f"{list(error.schema_path)}, {error.message},\n " +831 raise ValidationError(message, action_config.input_schema, inputs, source="input") +832 +833 self._validate_auth(context) +834 +835 # Create handler instance and execute +836 handler = self._action_handlers[name]() +837 result = await handler.execute(inputs, context) +838 +839 # Handle ActionError - skip output schema validation +840 if isinstance(result, ActionError): +841 return IntegrationResult( +842 version=__version__, +843 type=ResultType.ACTION_ERROR, +844 result=result +845 ) +846 +847 # Validate that result is ActionResult +848 if not isinstance(result, ActionResult): +849 raise ValidationError( +850 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", +851 source="output" +852 ) +853 +854 # Validate output schema against the data inside ActionResult +855 validator = Draft7Validator(action_config.output_schema) +856 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) +857 if errors: +858 message = "" +859 for error in errors: +860 message += f"{list(error.schema_path)}, {error.message},\n " +861 raise ValidationError(message, action_config.output_schema, result.data, source="output") +862 +863 # Return IntegrationResult with ActionResult directly +864 return IntegrationResult( +865 version=__version__, +866 type=ResultType.ACTION, +867 result=result +868 ) +869 except ValidationError as e: +870 return IntegrationResult( +871 version=__version__, +872 type=ResultType.VALIDATION_ERROR, +873 result={ +874 'message': str(e), +875 'property': None, +876 'value': None, +877 'source': getattr(e, 'source', 'legacy') +878 } +879 ) +880 +881 async def execute_polling_trigger(self, +882 name: str, +883 inputs: Dict[str, Any], +884 last_poll_ts: Optional[str], +885 context: ExecutionContext) -> List[Dict[str, Any]]: +886 """Execute a registered polling trigger +887 +888 Args: +889 name: Name of the polling trigger to execute +890 inputs: Trigger inputs +891 last_poll_ts: Last poll timestamp +892 context: Execution context +893 +894 Returns: +895 List of records +896 +897 Raises: +898 ValidationError: If inputs or outputs don't match schema +899 """ +900 if name not in self._polling_handlers: +901 raise ValidationError(f"Polling trigger '{name}' not registered") +902 +903 # Validate trigger configuration +904 trigger_config = self.config.polling_triggers[name] +905 try: +906 validate(inputs, trigger_config.input_schema) +907 except Exception as e: +908 raise ValidationError(e.message, e.schema, e.instance) +909 +910 self._validate_auth(context) +911 +912 # Create handler instance and execute +913 handler = self._polling_handlers[name]() +914 records = await handler.poll(inputs, last_poll_ts, context) +915 # Validate each record +916 for record in records: +917 if "id" not in record: +918 raise ValidationError( +919 f"Polling trigger '{name}' returned record without required 'id' field") +920 if "data" not in record: +921 raise ValidationError( +922 f"Polling trigger '{name}' returned record without required 'data' field") +923 +924 # Validate record data against output schema +925 try: +926 validate(record["data"], trigger_config.output_schema) +927 except Exception as e: +928 raise ValidationError(e.message, e.schema, e.instance) +929 +930 return records +931 +932 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: +933 """Get connected account information +934 +935 Args: +936 context: Execution context +937 +938 Returns: +939 IntegrationResult containing connected account data +940 +941 Raises: +942 ValidationError: If no connected account handler is registered or auth is invalid +943 """ +944 if not self._connected_account_handler: +945 raise ValidationError("No connected account handler registered") +946 +947 self._validate_auth(context) +948 +949 handler = self._connected_account_handler() +950 account_info = await handler.get_account_info(context) +951 +952 if not isinstance(account_info, ConnectedAccountInfo): +953 raise ValidationError( +954 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" +955 ) 956 -957 if "fields" in self.config.auth: -958 auth_config = self.config.auth["fields"] -959 validator = Draft7Validator(auth_config) -960 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -961 if errors: -962 message = "" -963 for error in errors: -964 message += f"{list(error.schema_path)}, {error.message},\n " -965 raise ValidationError(message, auth_config, context.auth) -966 -967 handler = self._connected_account_handler() -968 previous_identity = (context._integration_name, context._integration_version) -969 context._set_integration_identity(self.config.name, self.config.version) -970 try: -971 account_info = await handler.get_account_info(context) -972 finally: -973 context._set_integration_identity(*previous_identity) -974 -975 if not isinstance(account_info, ConnectedAccountInfo): -976 raise ValidationError( -977 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" -978 ) -979 -980 # Return IntegrationResult with ConnectedAccountInfo object directly -981 return IntegrationResult( -982 version=__version__, -983 type=ResultType.CONNECTED_ACCOUNT, -984 result=account_info -985 ) +957 # Return IntegrationResult with ConnectedAccountInfo object directly +958 return IntegrationResult( +959 version=__version__, +960 type=ResultType.CONNECTED_ACCOUNT, +961 result=account_info +962 )- - DEFAULT_USER_AGENT -
- AuthType
@@ -495,976 +492,939 @@
Default User-Agent sent by ExecutionContext.fetch() when not overridden.
70class AuthType(Enum): -71 """Authentication strategy used by an integration. -72 -73 The platform stores the auth type alongside credentials and passes both -74 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide -75 whether to auto-inject an ``Authorization`` header. -76 -77 Members: -78 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the -79 token lifecycle and injects ``Bearer <access_token>`` automatically. -80 PlatformTeams: Platform-managed Microsoft Teams auth. -81 ApiKey: A single API key provided by the user. -82 Basic: Username/password (HTTP Basic) credentials. -83 Custom: Free-form credential fields defined by the integration's -84 ``config.json`` auth schema. The integration is responsible for -85 reading individual fields from ``context.auth``. -86 """ -87 PlatformOauth2 = "PlatformOauth2" -88 PlatformTeams = "PlatformTeams" -89 ApiKey = "ApiKey" -90 Basic = "Basic" -91 Custom = "Custom" +@@ -1520,11 +1480,11 @@57class AuthType(Enum): +58 """Authentication strategy used by an integration. +59 +60 The platform stores the auth type alongside credentials and passes both +61 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide +62 whether to auto-inject an ``Authorization`` header. +63 +64 Members: +65 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the +66 token lifecycle and injects ``Bearer <access_token>`` automatically. +67 PlatformTeams: Platform-managed Microsoft Teams auth. +68 ApiKey: A single API key provided by the user. +69 Basic: Username/password (HTTP Basic) credentials. +70 Custom: Free-form credential fields defined by the integration's +71 ``config.json`` auth schema. The integration is responsible for +72 reading individual fields from ``context.auth``. +73 """ +74 PlatformOauth2 = "PlatformOauth2" +75 PlatformTeams = "PlatformTeams" +76 ApiKey = "ApiKey" +77 Basic = "Basic" +78 Custom = "Custom"PlatformOauth2 = <AuthType.PlatformOauth2: 'PlatformOauth2'> - +
93class ResultType(Enum): -94 """Type of result being returned""" -95 ACTION = "action" -96 ACTION_ERROR = "action_error" -97 CONNECTED_ACCOUNT = "connected_account" -98 ERROR = "error" -99 VALIDATION_ERROR = "validation_error" +@@ -1606,11 +1566,11 @@80class ResultType(Enum): +81 """Type of result being returned""" +82 ACTION = "action" +83 ACTION_ERROR = "action_error" +84 CONNECTED_ACCOUNT = "connected_account" +85 ERROR = "error" +86 VALIDATION_ERROR = "validation_error"ACTION = <ResultType.ACTION: 'action'> - +
102class ValidationError(Exception): -103 """Raised when SDK validation fails. -104 -105 This covers several cases: -106 -107 - Action inputs don't match the ``input_schema`` in ``config.json`` -108 - Action outputs don't match the ``output_schema`` -109 - Auth credentials don't match the ``auth.fields`` schema -110 - An action handler returns something other than ``ActionResult`` -111 - A handler name isn't registered -112 """ -113 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): -114 self.schema = schema -115 """The schema that failed validation""" -116 self.inputs = inputs -117 """The data that failed validation""" -118 self.message = message -119 """The error message""" -120 self.source = source -121 """Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)""" -122 super().__init__(message) +@@ -1714,37 +1674,37 @@89class ValidationError(Exception): + 90 """Raised when SDK validation fails. + 91 + 92 This covers several cases: + 93 + 94 - Action inputs don't match the ``input_schema`` in ``config.json`` + 95 - Action outputs don't match the ``output_schema`` + 96 - Auth credentials don't match the ``auth.fields`` schema + 97 - An action handler returns something other than ``ActionResult`` + 98 - A handler name isn't registered + 99 """ +100 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): +101 self.schema = schema +102 """The schema that failed validation""" +103 self.inputs = inputs +104 """The data that failed validation""" +105 self.message = message +106 """The error message""" +107 self.source = source +108 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" +109 super().__init__(message)
- + ValidationError( message: str, schema: str = None, inputs: str = None, source: str = 'legacy')-113 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): -114 self.schema = schema -115 """The schema that failed validation""" -116 self.inputs = inputs -117 """The data that failed validation""" -118 self.message = message -119 """The error message""" -120 self.source = source -121 """Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)""" -122 super().__init__(message) +- +100 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): +101 self.schema = schema +102 """The schema that failed validation""" +103 self.inputs = inputs +104 """The data that failed validation""" +105 self.message = message +106 """The error message""" +107 self.source = source +108 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" +109 super().__init__(message)schema - +- +@@ -1754,10 +1714,10 @@The schema that failed validation
inputs - +- +@@ -1767,10 +1727,10 @@The data that failed validation
message - +- +@@ -1780,11 +1740,11 @@The error message
source - +- -Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)
+ +@@ -1793,7 +1753,7 @@Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)
- + class ConfigurationError-(builtins.Exception): @@ -1801,9 +1761,9 @@
124class ConfigurationError(Exception): -125 """Raised when integration configuration is invalid""" -126 pass +@@ -1815,7 +1775,7 @@111class ConfigurationError(Exception): +112 """Raised when integration configuration is invalid""" +113 pass
- + class HTTPError-(builtins.Exception): @@ -1823,16 +1783,16 @@
128class HTTPError(Exception): -129 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" -130 def __init__(self, status: int, message: str, response_data: Any = None): -131 self.status = status -132 """Status code""" -133 self.message = message -134 """Error message""" -135 self.response_data = response_data -136 """Response data""" -137 super().__init__(f"HTTP {status}: {message}") +@@ -1843,35 +1803,35 @@115class HTTPError(Exception): +116 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" +117 def __init__(self, status: int, message: str, response_data: Any = None): +118 self.status = status +119 """Status code""" +120 self.message = message +121 """Error message""" +122 self.response_data = response_data +123 """Response data""" +124 super().__init__(f"HTTP {status}: {message}")
- + HTTPError(status: int, message: str, response_data: Any = None)-130 def __init__(self, status: int, message: str, response_data: Any = None): -131 self.status = status -132 """Status code""" -133 self.message = message -134 """Error message""" -135 self.response_data = response_data -136 """Response data""" -137 super().__init__(f"HTTP {status}: {message}") + - +status - +- +@@ -1881,10 +1841,10 @@Status code
message - +- +@@ -1894,10 +1854,10 @@Error message
response_data - +- +@@ -1907,7 +1867,7 @@Response data
- -139class RateLimitError(HTTPError): -140 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). -141 -142 Attributes: -143 retry_after: Seconds to wait before retrying, taken from the -144 ``Retry-After`` response header (defaults to 60 if absent). -145 """ -146 def __init__(self, retry_after: int, *args, **kwargs): -147 self.retry_after = retry_after -148 """Seconds to wait before retrying.""" -149 super().__init__(*args, **kwargs) +@@ -1940,31 +1900,31 @@126class RateLimitError(HTTPError): +127 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). +128 +129 Attributes: +130 retry_after: Seconds to wait before retrying, taken from the +131 ``Retry-After`` response header (defaults to 60 if absent). +132 """ +133 def __init__(self, retry_after: int, *args, **kwargs): +134 self.retry_after = retry_after +135 """Seconds to wait before retrying.""" +136 super().__init__(*args, **kwargs)
@@ -2627,13 +2587,13 @@- + RateLimitError(retry_after: int, *args, **kwargs)--146 def __init__(self, retry_after: int, *args, **kwargs): -147 self.retry_after = retry_after -148 """Seconds to wait before retrying.""" -149 super().__init__(*args, **kwargs) + - +-152@dataclass -153class FetchResponse: -154 """Response object returned by ``ExecutionContext.fetch()``. -155 -156 Wraps the full HTTP response so callers can inspect status codes and -157 headers in addition to the parsed body. -158 -159 Attributes: -160 status: HTTP status code (e.g. ``200``, ``201``). -161 headers: Response headers as a plain ``dict``. -162 data: Parsed JSON (``dict``/``list``) when the response is -163 ``application/json``, otherwise the raw response text. -164 ``None`` for empty 200/201/204 responses. -165 """ -166 status: int -167 headers: Dict[str, str] -168 data: Any +@@ -2030,47 +1990,47 @@139@dataclass +140class FetchResponse: +141 """Response object returned by ``ExecutionContext.fetch()``. +142 +143 Wraps the full HTTP response so callers can inspect status codes and +144 headers in addition to the parsed body. +145 +146 Attributes: +147 status: HTTP status code (e.g. ``200``, ``201``). +148 headers: Response headers as a plain ``dict``. +149 data: Parsed JSON (``dict``/``list``) when the response is +150 ``application/json``, otherwise the raw response text. +151 ``None`` for empty 200/201/204 responses. +152 """ +153 status: int +154 headers: Dict[str, str] +155 data: AnyInherited Members
@@ -2086,27 +2046,27 @@Inherited Members
-170@dataclass -171class ActionResult: -172 """Result returned by action handlers. -173 -174 This class encapsulates the data returned by an action along with optional -175 billing information for cost tracking. -176 -177 Args: -178 data: The actual result data from the action -179 cost_usd: Optional USD cost for billing purposes -180 -181 Example: -182 ```python -183 return ActionResult( -184 data={"message": "Success", "result": 42}, -185 cost_usd=0.05 -186 ) -187 ``` -188 """ -189 data: Any -190 cost_usd: Optional[float] = None +@@ -2134,37 +2094,37 @@157@dataclass +158class ActionResult: +159 """Result returned by action handlers. +160 +161 This class encapsulates the data returned by an action along with optional +162 billing information for cost tracking. +163 +164 Args: +165 data: The actual result data from the action +166 cost_usd: Optional USD cost for billing purposes +167 +168 Example: +169 ```python +170 return ActionResult( +171 data={"message": "Success", "result": 42}, +172 cost_usd=0.05 +173 ) +174 ``` +175 """ +176 data: Any +177 cost_usd: Optional[float] = NoneInherited Members
@@ -2180,27 +2140,27 @@+ + ActionResult(data: Any, cost_usd: float | None = None) - ActionResult(data: Any, cost_usd: Optional[float] = None) - - +- - + +Inherited Members
-192@dataclass -193class ActionError: -194 """Error result returned by action handlers for expected/application-level errors. -195 -196 When returned from an action handler, output schema validation is skipped -197 and the error is returned to the caller as a ResultType.ERROR result. -198 -199 Args: -200 message: Human-readable error message -201 cost_usd: Optional USD cost incurred before the error occurred -202 -203 Example: -204 ```python -205 return ActionError( -206 message="User not found", -207 cost_usd=0.01 -208 ) -209 ``` -210 """ -211 message: str -212 cost_usd: Optional[float] = None +@@ -2228,37 +2188,37 @@179@dataclass +180class ActionError: +181 """Error result returned by action handlers for expected/application-level errors. +182 +183 When returned from an action handler, output schema validation is skipped +184 and the error is returned to the caller as a ResultType.ERROR result. +185 +186 Args: +187 message: Human-readable error message +188 cost_usd: Optional USD cost incurred before the error occurred +189 +190 Example: +191 ```python +192 return ActionError( +193 message="User not found", +194 cost_usd=0.01 +195 ) +196 ``` +197 """ +198 message: str +199 cost_usd: Optional[float] = NoneInherited Members
@@ -2274,23 +2234,23 @@+ + ActionError(message: str, cost_usd: float | None = None) - ActionError(message: str, cost_usd: Optional[float] = None) - - +- - + +Inherited Members
-214@dataclass -215class ConnectedAccountInfo: -216 """Account metadata returned by a ``ConnectedAccountHandler``. -217 -218 The platform calls the connected-account handler after a user links -219 their external account. The returned info is displayed in the -220 Autohive UI (avatar, name, email, etc.). -221 -222 All fields are optional — populate whichever ones the external API provides. -223 """ -224 email: Optional[str] = None -225 first_name: Optional[str] = None -226 last_name: Optional[str] = None -227 username: Optional[str] = None -228 user_id: Optional[str] = None -229 avatar_url: Optional[str] = None -230 organization: Optional[str] = None +@@ -2306,98 +2266,98 @@201@dataclass +202class ConnectedAccountInfo: +203 """Account metadata returned by a ``ConnectedAccountHandler``. +204 +205 The platform calls the connected-account handler after a user links +206 their external account. The returned info is displayed in the +207 Autohive UI (avatar, name, email, etc.). +208 +209 All fields are optional — populate whichever ones the external API provides. +210 """ +211 email: Optional[str] = None +212 first_name: Optional[str] = None +213 last_name: Optional[str] = None +214 username: Optional[str] = None +215 user_id: Optional[str] = None +216 avatar_url: Optional[str] = None +217 organization: Optional[str] = NoneInherited Members
@@ -2413,29 +2373,29 @@+ + ConnectedAccountInfo( email: str | None = None, first_name: str | None = None, last_name: str | None = None, username: str | None = None, user_id: str | None = None, avatar_url: str | None = None, organization: str | None = None) - ConnectedAccountInfo( email: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, avatar_url: Optional[str] = None, organization: Optional[str] = None) - - +- - + +Inherited Members
-232@dataclass -233class IntegrationResult: -234 """Result format sent from lambda wrapper to backend. -235 -236 This class represents the standardized format that the lambda wrapper -237 sends to the Autohive backend, including SDK version and type-specific data. -238 -239 Args: -240 version: SDK version (auto-populated) -241 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) -242 result: The result object - ActionResult for actions, ActionError for -243 application-level action errors, or ConnectedAccountInfo for -244 connected accounts. -245 The lambda wrapper serializes these to dicts using asdict(). -246 -247 Note: -248 This type is returned by Integration methods and serialized by the lambda wrapper. -249 Integration developers should use ActionResult for action handlers and -250 ActionError for expected error conditions. -251 """ -252 version: str -253 type: ResultType -254 result: Union[ActionResult, ActionError, ConnectedAccountInfo] +@@ -2461,47 +2421,47 @@219@dataclass +220class IntegrationResult: +221 """Result format sent from lambda wrapper to backend. +222 +223 This class represents the standardized format that the lambda wrapper +224 sends to the Autohive backend, including SDK version and type-specific data. +225 +226 Args: +227 version: SDK version (auto-populated) +228 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) +229 result: The result object - ActionResult for actions, ActionError for +230 application-level action errors, or ConnectedAccountInfo for +231 connected accounts. +232 The lambda wrapper serializes these to dicts using asdict(). +233 +234 Note: +235 This type is returned by Integration methods and serialized by the lambda wrapper. +236 Integration developers should use ActionResult for action handlers and +237 ActionError for expected error conditions. +238 """ +239 version: str +240 type: ResultType +241 result: Union[ActionResult, ActionError, ConnectedAccountInfo]Inherited Members
+ + IntegrationResult( version: str, type: ResultType, result: ActionResult | ActionError | ConnectedAccountInfo) - IntegrationResult( version: str, type: ResultType, result: Union[ActionResult, ActionError, ConnectedAccountInfo]) - - +- - + +type: ResultType - +- - + +@@ -2517,15 +2477,15 @@- result: Union[ActionResult, ActionError, ConnectedAccountInfo] - + result: ActionResult | ActionError | ConnectedAccountInfo +- - + +Inherited Members
258@dataclass -259class Parameter: -260 """Definition of a parameter""" -261 name: str -262 type: str -263 description: str -264 enum: Optional[List[str]] = None -265 required: bool = True -266 default: Any = None +@@ -2535,59 +2495,59 @@245@dataclass +246class Parameter: +247 """Definition of a parameter""" +248 name: str +249 type: str +250 description: str +251 enum: Optional[List[str]] = None +252 required: bool = True +253 default: Any = NoneInherited Members
+ + Parameter( name: str, type: str, description: str, enum: List[str] | None = None, required: bool = True, default: Any = None) - Parameter( name: str, type: str, description: str, enum: Optional[List[str]] = None, required: bool = True, default: Any = None) - - +- - + +@@ -2595,11 +2555,11 @@- - + +Inherited Members
required: bool = True - +@@ -2607,11 +2567,11 @@- - + +Inherited Members
default: Any = None - +Inherited Members
-268@dataclass -269class SchemaDefinition: -270 """Base class for components that have input/output schemas""" -271 name: str -272 description: str -273 input_schema: List[Parameter] -274 output_schema: Optional[Dict[str, Any]] = None +@@ -2643,59 +2603,59 @@255@dataclass +256class SchemaDefinition: +257 """Base class for components that have input/output schemas""" +258 name: str +259 description: str +260 input_schema: List[Parameter] +261 output_schema: Optional[Dict[str, Any]] = NoneInherited Members
+ + SchemaDefinition( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None) - SchemaDefinition( name: str, description: str, input_schema: List[Parameter], output_schema: Optional[Dict[str, Any]] = None) - - +- - + +input_schema: List[Parameter] - +- - + +@@ -2711,10 +2671,10 @@- output_schema: Optional[Dict[str, Any]] = + output_schema: Dict[str, Any] | None = None - +- - + +Inherited Members
276@dataclass -277class Action(SchemaDefinition): -278 """Empty dataclass that inherits from SchemaDefinition""" -279 pass +@@ -2724,14 +2684,14 @@263@dataclass +264class Action(SchemaDefinition): +265 """Empty dataclass that inherits from SchemaDefinition""" +266 passInherited Members
+ + Action( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None) - Action( name: str, description: str, input_schema: List[Parameter], output_schema: Optional[Dict[str, Any]] = None) - - +- - + +@@ -2759,10 +2719,10 @@-Inherited Members
281@dataclass -282class PollingTrigger(SchemaDefinition): -283 """Definition of a polling trigger""" -284 polling_interval: timedelta = field(default_factory=timedelta) +@@ -2772,25 +2732,25 @@268@dataclass +269class PollingTrigger(SchemaDefinition): +270 """Definition of a polling trigger""" +271 polling_interval: timedelta = field(default_factory=timedelta)Inherited Members
+ + PollingTrigger( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None, polling_interval: datetime.timedelta = <factory>) - PollingTrigger( name: str, description: str, input_schema: List[Parameter], output_schema: Optional[Dict[str, Any]] = None, polling_interval: datetime.timedelta = <factory>) - - +- - + +@@ -2818,15 +2778,15 @@-Inherited Members
286@dataclass -287class IntegrationConfig: -288 """Configuration for an integration""" -289 name: str -290 version: str -291 description: str -292 auth: Dict[str, Any] -293 actions: Dict[str, Action] -294 polling_triggers: Dict[str, PollingTrigger] +@@ -2836,87 +2796,87 @@273@dataclass +274class IntegrationConfig: +275 """Configuration for an integration""" +276 name: str +277 version: str +278 description: str +279 auth: Dict[str, Any] +280 actions: Dict[str, Action] +281 polling_triggers: Dict[str, PollingTrigger]Inherited Members
- + IntegrationConfig( name: str, version: str, description: str, auth: Dict[str, Any], actions: Dict[str, Action], polling_triggers: Dict[str, PollingTrigger]) - +- - + +actions: Dict[str, Action] - +- - + +polling_triggers: Dict[str, PollingTrigger] - +- - + + - + class ActionHandler-(abc.ABC): @@ -2924,32 +2884,32 @@ Inherited Members
-297class ActionHandler(ABC): -298 """Base class for action handlers. -299 -300 Subclass this and implement ``execute()`` to handle a specific action. -301 Register it with the ``@integration.action("action_name")`` decorator. -302 -303 Example:: -304 -305 @integration.action("get_user") -306 class GetUser(ActionHandler): -307 async def execute(self, inputs, context): -308 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data -309 return ActionResult(data=user) -310 """ -311 @abstractmethod -312 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: -313 """Run the action logic. -314 -315 Args: -316 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. -317 context: Execution context providing ``fetch()``, ``auth``, and logging. -318 -319 Returns: -320 An ``ActionResult`` containing the output data and optional ``cost_usd``. -321 """ -322 pass +@@ -2981,18 +2941,18 @@284class ActionHandler(ABC): +285 """Base class for action handlers. +286 +287 Subclass this and implement ``execute()`` to handle a specific action. +288 Register it with the ``@integration.action("action_name")`` decorator. +289 +290 Example:: +291 +292 @integration.action("get_user") +293 class GetUser(ActionHandler): +294 async def execute(self, inputs, context): +295 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data +296 return ActionResult(data=user) +297 """ +298 @abstractmethod +299 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: +300 """Run the action logic. +301 +302 Args: +303 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. +304 context: Execution context providing ``fetch()``, ``auth``, and logging. +305 +306 Returns: +307 An ``ActionResult`` containing the output data and optional ``cost_usd``. +308 """ +309 passInherited Members
311 @abstractmethod -312 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: -313 """Run the action logic. -314 -315 Args: -316 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. -317 context: Execution context providing ``fetch()``, ``auth``, and logging. -318 -319 Returns: -320 An ``ActionResult`` containing the output data and optional ``cost_usd``. -321 """ -322 pass +@@ -3012,7 +2972,7 @@298 @abstractmethod +299 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: +300 """Run the action logic. +301 +302 Args: +303 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. +304 context: Execution context providing ``fetch()``, ``auth``, and logging. +305 +306 Returns: +307 An ``ActionResult`` containing the output data and optional ``cost_usd``. +308 """ +309 passInherited Members
- + class PollingTriggerHandler-(abc.ABC): @@ -3020,12 +2980,12 @@ Inherited Members
-324class PollingTriggerHandler(ABC): -325 """Base class for polling trigger handlers""" -326 @abstractmethod -327 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: -328 """Execute the polling trigger""" -329 pass +@@ -3039,16 +2999,16 @@311class PollingTriggerHandler(ABC): +312 """Base class for polling trigger handlers""" +313 @abstractmethod +314 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: +315 """Execute the polling trigger""" +316 passInherited Members
@abstractmethodasync def - poll( self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: ExecutionContext) -> List[Dict[str, Any]]: + poll( self, inputs: Dict[str, Any], last_poll_ts: str | None, context: ExecutionContext) -> List[Dict[str, Any]]:326 @abstractmethod -327 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: -328 """Execute the polling trigger""" -329 pass +@@ -3061,7 +3021,7 @@313 @abstractmethod +314 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: +315 """Execute the polling trigger""" +316 passInherited Members
- + class ConnectedAccountHandler-(abc.ABC): @@ -3069,37 +3029,37 @@ Inherited Members
-331class ConnectedAccountHandler(ABC): -332 """Base class for connected-account handlers. -333 -334 The platform calls this after a user links their external account. -335 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. -336 -337 Register with the ``@integration.connected_account()`` decorator. -338 -339 Example:: -340 -341 @integration.connected_account() -342 class MyAccountHandler(ConnectedAccountHandler): -343 async def get_account_info(self, context): -344 me = (await context.fetch("https://api.example.com/me")).data -345 return ConnectedAccountInfo( -346 email=me["email"], -347 first_name=me["first_name"], -348 last_name=me["last_name"], -349 ) -350 """ -351 @abstractmethod -352 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: -353 """Fetch account metadata from the external service. -354 -355 For platform OAuth integrations, ``context.fetch()`` auto-injects -356 the Bearer token — no manual auth handling needed. -357 -358 Returns: -359 A ``ConnectedAccountInfo`` with whichever fields the API provides. -360 """ -361 pass +@@ -3137,17 +3097,17 @@318class ConnectedAccountHandler(ABC): +319 """Base class for connected-account handlers. +320 +321 The platform calls this after a user links their external account. +322 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. +323 +324 Register with the ``@integration.connected_account()`` decorator. +325 +326 Example:: +327 +328 @integration.connected_account() +329 class MyAccountHandler(ConnectedAccountHandler): +330 async def get_account_info(self, context): +331 me = (await context.fetch("https://api.example.com/me")).data +332 return ConnectedAccountInfo( +333 email=me["email"], +334 first_name=me["first_name"], +335 last_name=me["last_name"], +336 ) +337 """ +338 @abstractmethod +339 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: +340 """Fetch account metadata from the external service. +341 +342 For platform OAuth integrations, ``context.fetch()`` auto-injects +343 the Bearer token — no manual auth handling needed. +344 +345 Returns: +346 A ``ConnectedAccountInfo`` with whichever fields the API provides. +347 """ +348 passInherited Members
351 @abstractmethod -352 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: -353 """Fetch account metadata from the external service. -354 -355 For platform OAuth integrations, ``context.fetch()`` auto-injects -356 the Bearer token — no manual auth handling needed. -357 -358 Returns: -359 A ``ConnectedAccountInfo`` with whichever fields the API provides. -360 """ -361 pass +@@ -3166,7 +3126,7 @@338 @abstractmethod +339 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: +340 """Fetch account metadata from the external service. +341 +342 For platform OAuth integrations, ``context.fetch()`` auto-injects +343 the Bearer token — no manual auth handling needed. +344 +345 Returns: +346 A ``ConnectedAccountInfo`` with whichever fields the API provides. +347 """ +348 passInherited Members
- + class ExecutionContext: @@ -3174,246 +3134,218 @@-Inherited Members
364class ExecutionContext: -365 """Context provided to integration handlers for making authenticated HTTP requests. -366 -367 Manages an ``aiohttp`` session with automatic retries, error handling, -368 default ``User-Agent`` handling, and optional Bearer-token injection for -369 platform OAuth integrations. -370 -371 Use as an async context manager:: -372 -373 async with ExecutionContext(auth=auth) as context: -374 result = await integration.execute_action("my_action", inputs, context) -375 -376 Args: -377 auth: Authentication data. In **local tests** this is a flat dict -378 matching the ``auth.fields`` schema in ``config.json`` -379 (e.g. ``{"api_key": "..."}``). In **production** the platform -380 wraps credentials as ``{"auth_type": "...", "credentials": {...}}``. -381 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). -382 metadata: Arbitrary metadata forwarded to handlers. -383 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. -384 """ -385 def __init__( -386 self, -387 auth: Dict[str, Any] = {}, -388 request_config: Optional[Dict[str, Any]] = None, -389 metadata: Optional[Dict[str, Any]] = None, -390 logger: Optional[logging.Logger] = None -391 ): -392 self.auth = auth -393 """Authentication configuration""" -394 self.config = request_config or {"max_retries": 3, "timeout": 30} -395 """Request configuration""" -396 self.metadata = metadata or {} -397 """Additional metadata""" -398 self.logger = logger or logging.getLogger(__name__) -399 """Logger instance""" -400 self._session: Optional[aiohttp.ClientSession] = None -401 self._integration_name: Optional[str] = None -402 self._integration_version: Optional[str] = None -403 -404 async def __aenter__(self): -405 if not self._session: -406 self._session = aiohttp.ClientSession() -407 return self -408 -409 async def __aexit__(self, exc_type, exc_val, exc_tb): -410 if self._session: -411 await self._session.close() -412 self._session = None -413 -414 def _set_integration_identity(self, name: Optional[str], version: Optional[str]) -> None: -415 """Attach integration identity for SDK-generated request metadata.""" -416 self._integration_name = name -417 self._integration_version = version -418 -419 def _build_default_user_agent(self) -> str: -420 if self._integration_name and self._integration_version: -421 integration_token = ( -422 f"{_sanitize_user_agent_token(self._integration_name)}/" -423 f"{_sanitize_user_agent_token(self._integration_version)}" -424 ) -425 return f"{DEFAULT_USER_AGENT} {integration_token}" -426 -427 return DEFAULT_USER_AGENT -428 -429 async def fetch( -430 self, -431 url: str, -432 method: str = "GET", -433 params: Optional[Dict[str, Any]] = None, -434 data: Any = None, -435 json: Any = None, -436 headers: Optional[Dict[str, str]] = None, -437 content_type: Optional[str] = None, -438 timeout: Optional[int] = None, -439 retry_count: int = 0, -440 user_agent: Optional[str] = None -441 ) -> FetchResponse: -442 """Make an HTTP request with automatic retries and error handling. -443 -444 If no ``User-Agent`` header is provided, a default SDK ``User-Agent`` is -445 added. When the request is made inside a handler executed by -446 ``Integration``, the integration's ``config.json`` name and version are -447 included. Pass ``user_agent`` to set a per-request value more easily. -448 Explicit ``User-Agent`` headers always take precedence. +351class ExecutionContext: +352 """Context provided to integration handlers for making authenticated HTTP requests. +353 +354 Manages an ``aiohttp`` session with automatic retries, error handling, and +355 optional Bearer-token injection for platform OAuth integrations. +356 +357 Use as an async context manager:: +358 +359 async with ExecutionContext(auth=auth) as context: +360 result = await integration.execute_action("my_action", inputs, context) +361 +362 Args: +363 auth: Authentication data. This is always the platform auth envelope +364 ``{"auth_type": "...", "credentials": {...}}``. The ``credentials`` +365 object matches the ``auth.fields`` schema in ``config.json``. Local +366 tests must use the same wrapped shape (e.g. +367 ``{"auth_type": "Custom", "credentials": {"api_key": "..."}}``); flat +368 auth is not supported. Handlers should read +369 individual credentials via ``context.auth["credentials"]`` (for +370 example ``context.auth["credentials"].get("api_key", "")``); strict +371 validation guarantees this shape exists before a handler runs. +372 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). +373 metadata: Arbitrary metadata forwarded to handlers. +374 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. +375 """ +376 def __init__( +377 self, +378 auth: Dict[str, Any] = {}, +379 request_config: Optional[Dict[str, Any]] = None, +380 metadata: Optional[Dict[str, Any]] = None, +381 logger: Optional[logging.Logger] = None +382 ): +383 self.auth = auth +384 """Authentication configuration""" +385 self.config = request_config or {"max_retries": 3, "timeout": 30} +386 """Request configuration""" +387 self.metadata = metadata or {} +388 """Additional metadata""" +389 self.logger = logger or logging.getLogger(__name__) +390 """Logger instance""" +391 self._session: Optional[aiohttp.ClientSession] = None +392 +393 async def __aenter__(self): +394 if not self._session: +395 self._session = aiohttp.ClientSession() +396 return self +397 +398 async def __aexit__(self, exc_type, exc_val, exc_tb): +399 if self._session: +400 await self._session.close() +401 self._session = None +402 +403 async def fetch( +404 self, +405 url: str, +406 method: str = "GET", +407 params: Optional[Dict[str, Any]] = None, +408 data: Any = None, +409 json: Any = None, +410 headers: Optional[Dict[str, str]] = None, +411 content_type: Optional[str] = None, +412 timeout: Optional[int] = None, +413 retry_count: int = 0 +414 ) -> FetchResponse: +415 """Make an HTTP request with automatic retries and error handling. +416 +417 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), +418 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` +419 unless an ``Authorization`` header is explicitly provided. +420 +421 Retries up to ``max_retries`` (default 3) on transient network errors +422 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` +423 immediately (no automatic retry). +424 +425 Args: +426 url: The URL to request. +427 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). +428 params: Query parameters appended to the URL. Nested dicts/lists +429 are JSON-serialized automatically. +430 data: Raw request body. Encoding depends on ``content_type``. +431 json: JSON-serializable payload. Sets ``content_type`` to +432 ``application/json`` automatically. +433 headers: Additional HTTP headers. Merged *after* any auto-injected +434 auth header, so an explicit ``Authorization`` takes precedence. +435 content_type: ``Content-Type`` header value. +436 timeout: Per-request timeout in seconds (overrides ``request_config``). +437 retry_count: Internal — current retry attempt number. +438 +439 Returns: +440 A ``FetchResponse`` containing the HTTP status code, response +441 headers, and parsed body data. +442 +443 Raises: +444 RateLimitError: On HTTP 429 with the ``Retry-After`` value. +445 HTTPError: On any other non-2xx status. +446 """ +447 if not self._session: +448 self._session = aiohttp.ClientSession() 449 -450 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), -451 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` -452 unless an ``Authorization`` header is explicitly provided. -453 -454 Retries up to ``max_retries`` (default 3) on transient network errors -455 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` -456 immediately (no automatic retry). -457 -458 Args: -459 url: The URL to request. -460 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). -461 params: Query parameters appended to the URL. Nested dicts/lists -462 are JSON-serialized automatically. -463 data: Raw request body. Encoding depends on ``content_type``. -464 json: JSON-serializable payload. Sets ``content_type`` to -465 ``application/json`` automatically. -466 headers: Additional HTTP headers. Merged *after* any auto-injected -467 auth header, so explicit ``Authorization`` and ``User-Agent`` -468 values take precedence. -469 user_agent: Convenience override for the request ``User-Agent``. -470 Ignored when ``headers`` already contains a ``User-Agent`` key. -471 content_type: ``Content-Type`` header value. -472 timeout: Per-request timeout in seconds (overrides ``request_config``). -473 retry_count: Internal — current retry attempt number. -474 -475 Returns: -476 A ``FetchResponse`` containing the HTTP status code, response -477 headers, and parsed body data. -478 -479 Raises: -480 RateLimitError: On HTTP 429 with the ``Retry-After`` value. -481 HTTPError: On any other non-2xx status. -482 """ -483 if not self._session: -484 self._session = aiohttp.ClientSession() -485 -486 # Prepare request -487 if json is not None: -488 data = json -489 content_type = "application/json" -490 -491 final_headers = {} +450 # Prepare request +451 if json is not None: +452 data = json +453 content_type = "application/json" +454 +455 final_headers = {} +456 +457 if self.auth and "Authorization" not in (headers or {}): +458 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) +459 credentials = self.auth.get("credentials", {}) +460 +461 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: +462 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" +463 +464 if content_type: +465 final_headers["Content-Type"] = content_type +466 if headers: +467 final_headers.update(headers) +468 +469 if params: +470 # Handle nested dictionary parameters +471 flat_params = {} +472 for key, value in params.items(): +473 if isinstance(value, (dict, list)): +474 flat_params[key] = jsonX.dumps(value) +475 elif value is not None: +476 flat_params[key] = str(value) +477 query_string = urlencode(flat_params) +478 url = f"{url}{'&' if '?' in url else '?'}{query_string}" +479 +480 # Prepare body +481 if data is not None: +482 if content_type == "application/json": +483 data = jsonX.dumps(data) +484 elif content_type == "application/x-www-form-urlencoded": +485 data = urlencode(data) if isinstance(data, dict) else data +486 +487 # Store the original timeout numeric value +488 original_timeout = timeout or self.config["timeout"] +489 +490 # Convert the numeric timeout to a ClientTimeout instance for this request +491 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 492 -493 if not any(key.lower() == "user-agent" for key in (headers or {})): -494 final_headers["User-Agent"] = user_agent or self._build_default_user_agent() -495 -496 if self.auth and "Authorization" not in (headers or {}): -497 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) -498 credentials = self.auth.get("credentials", {}) -499 -500 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: -501 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" -502 -503 if content_type: -504 final_headers["Content-Type"] = content_type -505 if headers: -506 final_headers.update(headers) -507 -508 if params: -509 # Handle nested dictionary parameters -510 flat_params = {} -511 for key, value in params.items(): -512 if isinstance(value, (dict, list)): -513 flat_params[key] = jsonX.dumps(value) -514 elif value is not None: -515 flat_params[key] = str(value) -516 query_string = urlencode(flat_params) -517 url = f"{url}{'&' if '?' in url else '?'}{query_string}" -518 -519 # Prepare body -520 if data is not None: -521 if content_type == "application/json": -522 data = jsonX.dumps(data) -523 elif content_type == "application/x-www-form-urlencoded": -524 data = urlencode(data) if isinstance(data, dict) else data +493 try: +494 async with self._session.request( +495 method=method, +496 url=url, +497 data=data, +498 headers=final_headers, +499 timeout=client_timeout, +500 ssl=True +501 ) as response: +502 content_type = response.headers.get("Content-Type", "") +503 +504 if response.status == 429: # Rate limit +505 retry_after = int(response.headers.get("Retry-After", 60)) +506 raise RateLimitError( +507 retry_after, +508 response.status, +509 "Rate limit exceeded", +510 await response.text() +511 ) +512 +513 try: +514 if "application/json" in content_type: +515 result = await response.json() +516 else: +517 result = await response.text() +518 if not result and response.status in {200, 201, 204}: +519 result = None +520 except Exception as e: +521 self.logger.error(f"Error parsing response: {e}") +522 result = await response.text() +523 +524 response_headers = dict(response.headers) 525 -526 # Store the original timeout numeric value -527 original_timeout = timeout or self.config["timeout"] -528 -529 # Convert the numeric timeout to a ClientTimeout instance for this request -530 client_timeout = aiohttp.ClientTimeout(total=original_timeout) -531 -532 try: -533 async with self._session.request( -534 method=method, -535 url=url, -536 data=data, -537 headers=final_headers, -538 timeout=client_timeout, -539 ssl=True -540 ) as response: -541 content_type = response.headers.get("Content-Type", "") -542 -543 if response.status == 429: # Rate limit -544 retry_after = int(response.headers.get("Retry-After", 60)) -545 raise RateLimitError( -546 retry_after, -547 response.status, -548 "Rate limit exceeded", -549 await response.text() -550 ) -551 -552 try: -553 if "application/json" in content_type: -554 result = await response.json() -555 else: -556 result = await response.text() -557 if not result and response.status in {200, 201, 204}: -558 result = None -559 except Exception as e: -560 self.logger.error(f"Error parsing response: {e}") -561 result = await response.text() -562 -563 response_headers = dict(response.headers) -564 -565 if not response.ok: -566 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") -567 raise HTTPError(response.status, str(result), result) -568 -569 return FetchResponse( -570 status=response.status, -571 headers=response_headers, -572 data=result, -573 ) -574 -575 except RateLimitError: -576 raise -577 except (aiohttp.ClientError, asyncio.TimeoutError) as e: -578 # Don't want to send this to Raygun here because this will be retried. -579 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") -580 if retry_count < self.config["max_retries"]: -581 await asyncio.sleep(2 ** retry_count) # Exponential backoff -582 print("Retrying request...") -583 # Use original_timeout (numeric) for recursive calls -584 return await self.fetch( -585 url, method, params, data, json, -586 headers, content_type, original_timeout, retry_count + 1, -587 user_agent=user_agent, -588 ) -589 else: -590 print("Max retries reached. Raising error.") -591 raise -592 except Exception as e: -593 self.logger.error(f"Unexpected error during {method} {url}: {e}") -594 print(f"Unexpected error encountered: {e}") -595 raise +526 if not response.ok: +527 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") +528 raise HTTPError(response.status, str(result), result) +529 +530 return FetchResponse( +531 status=response.status, +532 headers=response_headers, +533 data=result, +534 ) +535 +536 except RateLimitError: +537 raise +538 except (aiohttp.ClientError, asyncio.TimeoutError) as e: +539 # Don't want to send this to Raygun here because this will be retried. +540 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") +541 if retry_count < self.config["max_retries"]: +542 await asyncio.sleep(2 ** retry_count) # Exponential backoff +543 print("Retrying request...") +544 # Use original_timeout (numeric) for recursive calls +545 return await self.fetch( +546 url, method, params, data, json, +547 headers, content_type, original_timeout, retry_count + 1 +548 ) +549 else: +550 print("Max retries reached. Raising error.") +551 raise +552 except Exception as e: +553 self.logger.error(f"Unexpected error during {method} {url}: {e}") +554 print(f"Unexpected error encountered: {e}") +555 raiseContext provided to integration handlers for making authenticated HTTP requests.
-Manages an
+aiohttpsession with automatic retries, error handling, -defaultUser-Agenthandling, and optional Bearer-token injection for -platform OAuth integrations.Manages an
aiohttpsession with automatic retries, error handling, and +optional Bearer-token injection for platform OAuth integrations.Use as an async context manager::
@@ -3422,10 +3354,15 @@Inherited Members
Args: - auth: Authentication data. In local tests this is a flat dict - matching the
@@ -3435,45 +3372,43 @@auth.fieldsschema inconfig.json- (e.g.{"api_key": "..."}). In production the platform - wraps credentials as{"auth_type": "...", "credentials": {...}}. + auth: Authentication data. This is always the platform auth envelope +{"auth_type": "...", "credentials": {...}}. Thecredentials+ object matches theauth.fieldsschema inconfig.json. Local + tests must use the same wrapped shape (e.g. +{"auth_type": "Custom", "credentials": {"api_key": "..."}}); flat + auth is not supported. Handlers should read + individual credentials viacontext.auth["credentials"](for + examplecontext.auth["credentials"].get("api_key", "")); strict + validation guarantees this shape exists before a handler runs. request_config: Override defaultmax_retries(3) andtimeout(30 s). metadata: Arbitrary metadata forwarded to handlers. logger: Custom logger; falls back tologging.getLogger(__name__).Inherited Members
- - ExecutionContext( auth: Dict[str, Any] = {}, request_config: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None, logger: Optional[logging.Logger] = None) + + ExecutionContext( auth: Dict[str, Any] = {}, request_config: Dict[str, Any] | None = None, metadata: Dict[str, Any] | None = None, logger: logging.Logger | None = None)-385 def __init__( -386 self, -387 auth: Dict[str, Any] = {}, -388 request_config: Optional[Dict[str, Any]] = None, -389 metadata: Optional[Dict[str, Any]] = None, -390 logger: Optional[logging.Logger] = None -391 ): -392 self.auth = auth -393 """Authentication configuration""" -394 self.config = request_config or {"max_retries": 3, "timeout": 30} -395 """Request configuration""" -396 self.metadata = metadata or {} -397 """Additional metadata""" -398 self.logger = logger or logging.getLogger(__name__) -399 """Logger instance""" -400 self._session: Optional[aiohttp.ClientSession] = None -401 self._integration_name: Optional[str] = None -402 self._integration_version: Optional[str] = None +- +376 def __init__( +377 self, +378 auth: Dict[str, Any] = {}, +379 request_config: Optional[Dict[str, Any]] = None, +380 metadata: Optional[Dict[str, Any]] = None, +381 logger: Optional[logging.Logger] = None +382 ): +383 self.auth = auth +384 """Authentication configuration""" +385 self.config = request_config or {"max_retries": 3, "timeout": 30} +386 """Request configuration""" +387 self.metadata = metadata or {} +388 """Additional metadata""" +389 self.logger = logger or logging.getLogger(__name__) +390 """Logger instance""" +391 self._session: Optional[aiohttp.ClientSession] = Noneauth - +- +@@ -3483,10 +3418,10 @@Authentication configuration
Inherited Members
config - +- +@@ -3496,10 +3431,10 @@Request configuration
Inherited Members
metadata - +- +@@ -3509,10 +3444,10 @@Additional metadata
Inherited Members
logger - +- +@@ -3521,192 +3456,172 @@Logger instance
Inherited Members
- + async def - fetch( self, url: str, method: str = 'GET', params: Optional[Dict[str, Any]] = None, data: Any = None, json: Any = None, headers: Optional[Dict[str, str]] = None, content_type: Optional[str] = None, timeout: Optional[int] = None, retry_count: int = 0, user_agent: Optional[str] = None) -> FetchResponse: + fetch( self, url: str, method: str = 'GET', params: Dict[str, Any] | None = None, data: Any = None, json: Any = None, headers: Dict[str, str] | None = None, content_type: str | None = None, timeout: int | None = None, retry_count: int = 0) -> FetchResponse:-429 async def fetch( -430 self, -431 url: str, -432 method: str = "GET", -433 params: Optional[Dict[str, Any]] = None, -434 data: Any = None, -435 json: Any = None, -436 headers: Optional[Dict[str, str]] = None, -437 content_type: Optional[str] = None, -438 timeout: Optional[int] = None, -439 retry_count: int = 0, -440 user_agent: Optional[str] = None -441 ) -> FetchResponse: -442 """Make an HTTP request with automatic retries and error handling. -443 -444 If no ``User-Agent`` header is provided, a default SDK ``User-Agent`` is -445 added. When the request is made inside a handler executed by -446 ``Integration``, the integration's ``config.json`` name and version are -447 included. Pass ``user_agent`` to set a per-request value more easily. -448 Explicit ``User-Agent`` headers always take precedence. +403 async def fetch( +404 self, +405 url: str, +406 method: str = "GET", +407 params: Optional[Dict[str, Any]] = None, +408 data: Any = None, +409 json: Any = None, +410 headers: Optional[Dict[str, str]] = None, +411 content_type: Optional[str] = None, +412 timeout: Optional[int] = None, +413 retry_count: int = 0 +414 ) -> FetchResponse: +415 """Make an HTTP request with automatic retries and error handling. +416 +417 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), +418 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` +419 unless an ``Authorization`` header is explicitly provided. +420 +421 Retries up to ``max_retries`` (default 3) on transient network errors +422 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` +423 immediately (no automatic retry). +424 +425 Args: +426 url: The URL to request. +427 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). +428 params: Query parameters appended to the URL. Nested dicts/lists +429 are JSON-serialized automatically. +430 data: Raw request body. Encoding depends on ``content_type``. +431 json: JSON-serializable payload. Sets ``content_type`` to +432 ``application/json`` automatically. +433 headers: Additional HTTP headers. Merged *after* any auto-injected +434 auth header, so an explicit ``Authorization`` takes precedence. +435 content_type: ``Content-Type`` header value. +436 timeout: Per-request timeout in seconds (overrides ``request_config``). +437 retry_count: Internal — current retry attempt number. +438 +439 Returns: +440 A ``FetchResponse`` containing the HTTP status code, response +441 headers, and parsed body data. +442 +443 Raises: +444 RateLimitError: On HTTP 429 with the ``Retry-After`` value. +445 HTTPError: On any other non-2xx status. +446 """ +447 if not self._session: +448 self._session = aiohttp.ClientSession() 449 -450 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), -451 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` -452 unless an ``Authorization`` header is explicitly provided. -453 -454 Retries up to ``max_retries`` (default 3) on transient network errors -455 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` -456 immediately (no automatic retry). -457 -458 Args: -459 url: The URL to request. -460 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). -461 params: Query parameters appended to the URL. Nested dicts/lists -462 are JSON-serialized automatically. -463 data: Raw request body. Encoding depends on ``content_type``. -464 json: JSON-serializable payload. Sets ``content_type`` to -465 ``application/json`` automatically. -466 headers: Additional HTTP headers. Merged *after* any auto-injected -467 auth header, so explicit ``Authorization`` and ``User-Agent`` -468 values take precedence. -469 user_agent: Convenience override for the request ``User-Agent``. -470 Ignored when ``headers`` already contains a ``User-Agent`` key. -471 content_type: ``Content-Type`` header value. -472 timeout: Per-request timeout in seconds (overrides ``request_config``). -473 retry_count: Internal — current retry attempt number. -474 -475 Returns: -476 A ``FetchResponse`` containing the HTTP status code, response -477 headers, and parsed body data. -478 -479 Raises: -480 RateLimitError: On HTTP 429 with the ``Retry-After`` value. -481 HTTPError: On any other non-2xx status. -482 """ -483 if not self._session: -484 self._session = aiohttp.ClientSession() -485 -486 # Prepare request -487 if json is not None: -488 data = json -489 content_type = "application/json" -490 -491 final_headers = {} +450 # Prepare request +451 if json is not None: +452 data = json +453 content_type = "application/json" +454 +455 final_headers = {} +456 +457 if self.auth and "Authorization" not in (headers or {}): +458 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) +459 credentials = self.auth.get("credentials", {}) +460 +461 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: +462 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" +463 +464 if content_type: +465 final_headers["Content-Type"] = content_type +466 if headers: +467 final_headers.update(headers) +468 +469 if params: +470 # Handle nested dictionary parameters +471 flat_params = {} +472 for key, value in params.items(): +473 if isinstance(value, (dict, list)): +474 flat_params[key] = jsonX.dumps(value) +475 elif value is not None: +476 flat_params[key] = str(value) +477 query_string = urlencode(flat_params) +478 url = f"{url}{'&' if '?' in url else '?'}{query_string}" +479 +480 # Prepare body +481 if data is not None: +482 if content_type == "application/json": +483 data = jsonX.dumps(data) +484 elif content_type == "application/x-www-form-urlencoded": +485 data = urlencode(data) if isinstance(data, dict) else data +486 +487 # Store the original timeout numeric value +488 original_timeout = timeout or self.config["timeout"] +489 +490 # Convert the numeric timeout to a ClientTimeout instance for this request +491 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 492 -493 if not any(key.lower() == "user-agent" for key in (headers or {})): -494 final_headers["User-Agent"] = user_agent or self._build_default_user_agent() -495 -496 if self.auth and "Authorization" not in (headers or {}): -497 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) -498 credentials = self.auth.get("credentials", {}) -499 -500 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: -501 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" -502 -503 if content_type: -504 final_headers["Content-Type"] = content_type -505 if headers: -506 final_headers.update(headers) -507 -508 if params: -509 # Handle nested dictionary parameters -510 flat_params = {} -511 for key, value in params.items(): -512 if isinstance(value, (dict, list)): -513 flat_params[key] = jsonX.dumps(value) -514 elif value is not None: -515 flat_params[key] = str(value) -516 query_string = urlencode(flat_params) -517 url = f"{url}{'&' if '?' in url else '?'}{query_string}" -518 -519 # Prepare body -520 if data is not None: -521 if content_type == "application/json": -522 data = jsonX.dumps(data) -523 elif content_type == "application/x-www-form-urlencoded": -524 data = urlencode(data) if isinstance(data, dict) else data +493 try: +494 async with self._session.request( +495 method=method, +496 url=url, +497 data=data, +498 headers=final_headers, +499 timeout=client_timeout, +500 ssl=True +501 ) as response: +502 content_type = response.headers.get("Content-Type", "") +503 +504 if response.status == 429: # Rate limit +505 retry_after = int(response.headers.get("Retry-After", 60)) +506 raise RateLimitError( +507 retry_after, +508 response.status, +509 "Rate limit exceeded", +510 await response.text() +511 ) +512 +513 try: +514 if "application/json" in content_type: +515 result = await response.json() +516 else: +517 result = await response.text() +518 if not result and response.status in {200, 201, 204}: +519 result = None +520 except Exception as e: +521 self.logger.error(f"Error parsing response: {e}") +522 result = await response.text() +523 +524 response_headers = dict(response.headers) 525 -526 # Store the original timeout numeric value -527 original_timeout = timeout or self.config["timeout"] -528 -529 # Convert the numeric timeout to a ClientTimeout instance for this request -530 client_timeout = aiohttp.ClientTimeout(total=original_timeout) -531 -532 try: -533 async with self._session.request( -534 method=method, -535 url=url, -536 data=data, -537 headers=final_headers, -538 timeout=client_timeout, -539 ssl=True -540 ) as response: -541 content_type = response.headers.get("Content-Type", "") -542 -543 if response.status == 429: # Rate limit -544 retry_after = int(response.headers.get("Retry-After", 60)) -545 raise RateLimitError( -546 retry_after, -547 response.status, -548 "Rate limit exceeded", -549 await response.text() -550 ) -551 -552 try: -553 if "application/json" in content_type: -554 result = await response.json() -555 else: -556 result = await response.text() -557 if not result and response.status in {200, 201, 204}: -558 result = None -559 except Exception as e: -560 self.logger.error(f"Error parsing response: {e}") -561 result = await response.text() -562 -563 response_headers = dict(response.headers) -564 -565 if not response.ok: -566 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") -567 raise HTTPError(response.status, str(result), result) -568 -569 return FetchResponse( -570 status=response.status, -571 headers=response_headers, -572 data=result, -573 ) -574 -575 except RateLimitError: -576 raise -577 except (aiohttp.ClientError, asyncio.TimeoutError) as e: -578 # Don't want to send this to Raygun here because this will be retried. -579 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") -580 if retry_count < self.config["max_retries"]: -581 await asyncio.sleep(2 ** retry_count) # Exponential backoff -582 print("Retrying request...") -583 # Use original_timeout (numeric) for recursive calls -584 return await self.fetch( -585 url, method, params, data, json, -586 headers, content_type, original_timeout, retry_count + 1, -587 user_agent=user_agent, -588 ) -589 else: -590 print("Max retries reached. Raising error.") -591 raise -592 except Exception as e: -593 self.logger.error(f"Unexpected error during {method} {url}: {e}") -594 print(f"Unexpected error encountered: {e}") -595 raise +526 if not response.ok: +527 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") +528 raise HTTPError(response.status, str(result), result) +529 +530 return FetchResponse( +531 status=response.status, +532 headers=response_headers, +533 data=result, +534 ) +535 +536 except RateLimitError: +537 raise +538 except (aiohttp.ClientError, asyncio.TimeoutError) as e: +539 # Don't want to send this to Raygun here because this will be retried. +540 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") +541 if retry_count < self.config["max_retries"]: +542 await asyncio.sleep(2 ** retry_count) # Exponential backoff +543 print("Retrying request...") +544 # Use original_timeout (numeric) for recursive calls +545 return await self.fetch( +546 url, method, params, data, json, +547 headers, content_type, original_timeout, retry_count + 1 +548 ) +549 else: +550 print("Max retries reached. Raising error.") +551 raise +552 except Exception as e: +553 self.logger.error(f"Unexpected error during {method} {url}: {e}") +554 print(f"Unexpected error encountered: {e}") +555 raiseMake an HTTP request with automatic retries and error handling.
-If no
-User-Agentheader is provided, a default SDKUser-Agentis -added. When the request is made inside a handler executed by -Integration, the integration'sconfig.jsonname and version are -included. Passuser_agentto set a per-request value more easily. -ExplicitUser-Agentheaders always take precedence.For platform OAuth integrations (
@@ -3724,10 +3639,7 @@auth_type == "PlatformOauth2"), aBearertoken is auto-injected fromauth.credentials.access_tokenunless anAuthorizationheader is explicitly provided.Inherited Members
json: JSON-serializable payload. Setscontent_typetoapplication/jsonautomatically. headers: Additional HTTP headers. Merged after any auto-injected - auth header, so explicitAuthorizationandUser-Agent- values take precedence. - user_agent: Convenience override for the requestUser-Agent. - Ignored whenheadersalready contains aUser-Agentkey. + auth header, so an explicitAuthorizationtakes precedence. content_type:Content-Typeheader value. timeout: Per-request timeout in seconds (overridesrequest_config). retry_count: Internal — current retry attempt number. @@ -3747,7 +3659,7 @@Inherited Members
- + class Integration: @@ -3755,395 +3667,412 @@-Inherited Members
598class Integration: -599 """Base integration class with handler registration and execution. -600 -601 This class manages the integration configuration, handler registration, -602 and provides methods to execute actions and triggers. -603 -604 Args: -605 config: Integration configuration -606 -607 Attributes: -608 config: Integration configuration -609 """ -610 -611 def __init__(self, config: IntegrationConfig): -612 self.config = config -613 """Integration configuration""" -614 self._action_handlers: Dict[str, Type[ActionHandler]] = {} -615 """Action handlers""" -616 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} -617 """Polling handlers""" -618 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None -619 """Connected account handler""" -620 -621 @classmethod -622 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': -623 """Load an integration from its ``config.json``. -624 -625 Args: -626 config_path: Explicit path to ``config.json``. When omitted the -627 SDK resolves the path relative to its own package location, -628 which works when the SDK is vendored via -629 ``pip install --target dependencies``. Multi-file integrations -630 that use ``actions/`` sub-packages should pass an explicit path -631 (e.g. ``Integration.load("config.json")``). -632 -633 Returns: -634 A fully initialised ``Integration`` ready for handler registration. -635 -636 Raises: -637 ConfigurationError: If the file is missing or contains invalid JSON. -638 """ -639 if config_path is None: -640 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') -641 -642 config_path = Path(config_path) -643 -644 if not config_path.exists(): -645 raise ConfigurationError(f"Configuration file not found: {config_path}") -646 -647 try: -648 with open(config_path, 'r') as f: -649 config_data = json.load(f) -650 except json.JSONDecodeError as e: -651 raise ConfigurationError(f"Invalid JSON configuration: {e}") -652 -653 # Parse configuration sections -654 actions = cls._parse_actions(config_data.get("actions", {})) -655 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) +@@ -4163,36 +4092,36 @@558class Integration: +559 """Base integration class with handler registration and execution. +560 +561 This class manages the integration configuration, handler registration, +562 and provides methods to execute actions and triggers. +563 +564 Args: +565 config: Integration configuration +566 +567 Attributes: +568 config: Integration configuration +569 """ +570 +571 def __init__(self, config: IntegrationConfig): +572 self.config = config +573 """Integration configuration""" +574 self._action_handlers: Dict[str, Type[ActionHandler]] = {} +575 """Action handlers""" +576 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} +577 """Polling handlers""" +578 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None +579 """Connected account handler""" +580 +581 @classmethod +582 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': +583 """Load an integration from its ``config.json``. +584 +585 Args: +586 config_path: Explicit path to ``config.json``. When omitted the +587 SDK resolves the path relative to its own package location, +588 which works when the SDK is vendored via +589 ``pip install --target dependencies``. Multi-file integrations +590 that use ``actions/`` sub-packages should pass an explicit path +591 (e.g. ``Integration.load("config.json")``). +592 +593 Returns: +594 A fully initialised ``Integration`` ready for handler registration. +595 +596 Raises: +597 ConfigurationError: If the file is missing or contains invalid JSON. +598 """ +599 if config_path is None: +600 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') +601 +602 config_path = Path(config_path) +603 +604 if not config_path.exists(): +605 raise ConfigurationError(f"Configuration file not found: {config_path}") +606 +607 try: +608 with open(config_path, 'r') as f: +609 config_data = json.load(f) +610 except json.JSONDecodeError as e: +611 raise ConfigurationError(f"Invalid JSON configuration: {e}") +612 +613 # Parse configuration sections +614 actions = cls._parse_actions(config_data.get("actions", {})) +615 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) +616 +617 config = IntegrationConfig( +618 name=config_data["name"], +619 version=config_data["version"], +620 description=config_data["description"], +621 auth=config_data.get("auth", {}), +622 actions=actions, +623 polling_triggers=polling_triggers +624 ) +625 +626 return cls(config) +627 +628 @staticmethod +629 def _parse_interval(interval_str: str) -> timedelta: +630 """Parse interval string into timedelta""" +631 unit = interval_str[-1].lower() +632 value = int(interval_str[:-1]) +633 +634 if unit == 's': +635 return timedelta(seconds=value) +636 elif unit == 'm': +637 return timedelta(minutes=value) +638 elif unit == 'h': +639 return timedelta(hours=value) +640 elif unit == 'd': +641 return timedelta(days=value) +642 else: +643 raise ConfigurationError(f"Invalid interval format: {interval_str}") +644 +645 @classmethod +646 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: +647 """Parse action configurations""" +648 actions = {} +649 for name, data in actions_config.items(): +650 actions[name] = Action( +651 name=name, +652 description=data["description"], +653 input_schema=data["input_schema"], +654 output_schema=data["output_schema"] +655 ) 656 -657 config = IntegrationConfig( -658 name=config_data["name"], -659 version=config_data["version"], -660 description=config_data["description"], -661 auth=config_data.get("auth", {}), -662 actions=actions, -663 polling_triggers=polling_triggers -664 ) +657 return actions +658 +659 @classmethod +660 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: +661 """Parse polling trigger configurations""" +662 triggers = {} +663 for name, data in triggers_config.items(): +664 interval = cls._parse_interval(data["polling_interval"]) 665 -666 return cls(config) -667 -668 @staticmethod -669 def _parse_interval(interval_str: str) -> timedelta: -670 """Parse interval string into timedelta""" -671 unit = interval_str[-1].lower() -672 value = int(interval_str[:-1]) +666 triggers[name] = PollingTrigger( +667 name=name, +668 description=data["description"], +669 polling_interval=interval, +670 input_schema=data["input_schema"], +671 output_schema=data["output_schema"] +672 ) 673 -674 if unit == 's': -675 return timedelta(seconds=value) -676 elif unit == 'm': -677 return timedelta(minutes=value) -678 elif unit == 'h': -679 return timedelta(hours=value) -680 elif unit == 'd': -681 return timedelta(days=value) -682 else: -683 raise ConfigurationError(f"Invalid interval format: {interval_str}") -684 -685 @classmethod -686 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: -687 """Parse action configurations""" -688 actions = {} -689 for name, data in actions_config.items(): -690 actions[name] = Action( -691 name=name, -692 description=data["description"], -693 input_schema=data["input_schema"], -694 output_schema=data["output_schema"] -695 ) -696 -697 return actions -698 -699 @classmethod -700 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: -701 """Parse polling trigger configurations""" -702 triggers = {} -703 for name, data in triggers_config.items(): -704 interval = cls._parse_interval(data["polling_interval"]) -705 -706 triggers[name] = PollingTrigger( -707 name=name, -708 description=data["description"], -709 polling_interval=interval, -710 input_schema=data["input_schema"], -711 output_schema=data["output_schema"] -712 ) -713 -714 return triggers +674 return triggers +675 +676 def action(self, name: str): +677 """Decorator to register an action handler. +678 +679 Args: +680 name: Name of the action to register +681 +682 Returns: +683 Decorator function +684 +685 Raises: +686 ConfigurationError: If action is not defined in config +687 +688 Example: +689 ```python +690 @integration.action("my_action") +691 class MyActionHandler(ActionHandler): +692 async def execute(self, inputs, context): +693 # Implementation +694 return result +695 ``` +696 """ +697 def decorator(handler_class: Type[ActionHandler]): +698 if name not in self.config.actions: +699 raise ConfigurationError(f"Action '{name}' not defined in config") +700 self._action_handlers[name] = handler_class +701 return handler_class +702 return decorator +703 +704 def polling_trigger(self, name: str): +705 """Decorator to register a polling trigger handler +706 +707 Args: +708 name: Name of the polling trigger to register +709 +710 Returns: +711 Decorator function +712 +713 Raises: +714 ConfigurationError: If polling trigger is not defined in config 715 -716 def action(self, name: str): -717 """Decorator to register an action handler. -718 -719 Args: -720 name: Name of the action to register -721 -722 Returns: -723 Decorator function -724 -725 Raises: -726 ConfigurationError: If action is not defined in config -727 -728 Example: -729 ```python -730 @integration.action("my_action") -731 class MyActionHandler(ActionHandler): -732 async def execute(self, inputs, context): -733 # Implementation -734 return result -735 ``` -736 """ -737 def decorator(handler_class: Type[ActionHandler]): -738 if name not in self.config.actions: -739 raise ConfigurationError(f"Action '{name}' not defined in config") -740 self._action_handlers[name] = handler_class -741 return handler_class -742 return decorator -743 -744 def polling_trigger(self, name: str): -745 """Decorator to register a polling trigger handler -746 -747 Args: -748 name: Name of the polling trigger to register -749 -750 Returns: -751 Decorator function -752 -753 Raises: -754 ConfigurationError: If polling trigger is not defined in config -755 -756 Example: -757 ```python -758 @integration.polling_trigger("my_polling_trigger") -759 class MyPollingTriggerHandler(PollingTriggerHandler): -760 async def poll(self, inputs, last_poll_ts, context): -761 # Implementation -762 return result -763 ``` -764 """ -765 def decorator(handler_class: Type[PollingTriggerHandler]): -766 if name not in self.config.polling_triggers: -767 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") -768 self._polling_handlers[name] = handler_class -769 return handler_class -770 return decorator +716 Example: +717 ```python +718 @integration.polling_trigger("my_polling_trigger") +719 class MyPollingTriggerHandler(PollingTriggerHandler): +720 async def poll(self, inputs, last_poll_ts, context): +721 # Implementation +722 return result +723 ``` +724 """ +725 def decorator(handler_class: Type[PollingTriggerHandler]): +726 if name not in self.config.polling_triggers: +727 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") +728 self._polling_handlers[name] = handler_class +729 return handler_class +730 return decorator +731 +732 def connected_account(self): +733 """Decorator to register a connected account handler +734 +735 Returns: +736 Decorator function +737 +738 Example: +739 ```python +740 @integration.connected_account() +741 class MyConnectedAccountHandler(ConnectedAccountHandler): +742 async def get_account_info(self, context): +743 # Implementation +744 return {"email": "user@example.com", "name": "John Doe"} +745 ``` +746 """ +747 def decorator(handler_class: Type[ConnectedAccountHandler]): +748 self._connected_account_handler = handler_class +749 return handler_class +750 return decorator +751 +752 def _validate_auth(self, context: ExecutionContext) -> None: +753 """Validate the auth envelope's credentials against ``auth.fields``. +754 +755 The platform always passes ``context.auth`` as the +756 wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The +757 ``auth.fields`` schema in ``config.json`` describes only the inner +758 ``credentials`` object, so validation runs against +759 ``context.auth["credentials"]`` — not the whole envelope. +760 +761 Integrations with no auth or no ``fields`` key skip validation entirely. +762 +763 Raises: +764 ValidationError: (source ``"auth"``) if ``context.auth`` is not a +765 wrapped envelope (a dict with a non-empty string ``auth_type`` +766 and a dict ``credentials``), or if the credentials fail the +767 schema. +768 """ +769 if "fields" not in self.config.auth: +770 return 771 -772 def connected_account(self): -773 """Decorator to register a connected account handler -774 -775 Returns: -776 Decorator function -777 -778 Example: -779 ```python -780 @integration.connected_account() -781 class MyConnectedAccountHandler(ConnectedAccountHandler): -782 async def get_account_info(self, context): -783 # Implementation -784 return {"email": "user@example.com", "name": "John Doe"} -785 ``` -786 """ -787 def decorator(handler_class: Type[ConnectedAccountHandler]): -788 self._connected_account_handler = handler_class -789 return handler_class -790 return decorator -791 -792 async def execute_action(self, -793 name: str, -794 inputs: Dict[str, Any], -795 context: ExecutionContext) -> IntegrationResult: -796 """Execute a registered action. -797 -798 Args: -799 name: Name of the action to execute -800 inputs: Action inputs -801 context: Execution context -802 -803 Returns: -804 IntegrationResult with action data (ResultType.ACTION), -805 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, -806 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. -807 """ -808 try: -809 if name not in self._action_handlers: -810 raise ValidationError(f"Action '{name}' not registered") -811 -812 # Validate inputs against action schema -813 action_config = self.config.actions[name] -814 validator = Draft7Validator(action_config.input_schema) -815 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) -816 if errors: -817 message = "" -818 for error in errors: -819 message += f"{list(error.schema_path)}, {error.message},\n " -820 raise ValidationError(message, action_config.input_schema, inputs, source="input") -821 -822 if "fields" in self.config.auth: -823 auth_config = self.config.auth["fields"] -824 validator = Draft7Validator(auth_config) -825 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -826 if errors: -827 message = "" -828 for error in errors: -829 message += f"{list(error.schema_path)}, {error.message},\n " -830 raise ValidationError(message, auth_config, context.auth, source="input") -831 -832 # Create handler instance and execute -833 handler = self._action_handlers[name]() -834 previous_identity = (context._integration_name, context._integration_version) -835 context._set_integration_identity(self.config.name, self.config.version) -836 try: -837 result = await handler.execute(inputs, context) -838 finally: -839 context._set_integration_identity(*previous_identity) -840 -841 # Handle ActionError - skip output schema validation -842 if isinstance(result, ActionError): -843 return IntegrationResult( -844 version=__version__, -845 type=ResultType.ACTION_ERROR, -846 result=result -847 ) -848 -849 # Validate that result is ActionResult -850 if not isinstance(result, ActionResult): -851 raise ValidationError( -852 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", -853 source="output" -854 ) -855 -856 # Validate output schema against the data inside ActionResult -857 validator = Draft7Validator(action_config.output_schema) -858 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) -859 if errors: -860 message = "" -861 for error in errors: -862 message += f"{list(error.schema_path)}, {error.message},\n " -863 raise ValidationError(message, action_config.output_schema, result.data, source="output") -864 -865 # Return IntegrationResult with ActionResult directly -866 return IntegrationResult( -867 version=__version__, -868 type=ResultType.ACTION, -869 result=result -870 ) -871 except ValidationError as e: -872 return IntegrationResult( -873 version=__version__, -874 type=ResultType.VALIDATION_ERROR, -875 result={ -876 'message': str(e), -877 'property': None, -878 'value': None, -879 'source': getattr(e, 'source', 'legacy') -880 } -881 ) -882 -883 async def execute_polling_trigger(self, -884 name: str, -885 inputs: Dict[str, Any], -886 last_poll_ts: Optional[str], -887 context: ExecutionContext) -> List[Dict[str, Any]]: -888 """Execute a registered polling trigger -889 -890 Args: -891 name: Name of the polling trigger to execute -892 inputs: Trigger inputs -893 last_poll_ts: Last poll timestamp -894 context: Execution context -895 -896 Returns: -897 List of records -898 -899 Raises: -900 ValidationError: If inputs or outputs don't match schema -901 """ -902 if name not in self._polling_handlers: -903 raise ValidationError(f"Polling trigger '{name}' not registered") -904 -905 # Validate trigger configuration -906 trigger_config = self.config.polling_triggers[name] -907 try: -908 validate(inputs, trigger_config.input_schema) -909 except Exception as e: -910 raise ValidationError(e.message, e.schema, e.instance) -911 -912 try: -913 auth_config = self.config.auth["fields"] -914 validate(context.auth, auth_config) -915 except Exception as e: -916 raise ValidationError(e.message, e.schema, e.instance) -917 -918 # Create handler instance and execute -919 handler = self._polling_handlers[name]() -920 previous_identity = (context._integration_name, context._integration_version) -921 context._set_integration_identity(self.config.name, self.config.version) -922 try: -923 records = await handler.poll(inputs, last_poll_ts, context) -924 finally: -925 context._set_integration_identity(*previous_identity) -926 # Validate each record -927 for record in records: -928 if "id" not in record: -929 raise ValidationError( -930 f"Polling trigger '{name}' returned record without required 'id' field") -931 if "data" not in record: -932 raise ValidationError( -933 f"Polling trigger '{name}' returned record without required 'data' field") -934 -935 # Validate record data against output schema -936 try: -937 validate(record["data"], trigger_config.output_schema) -938 except Exception as e: -939 raise ValidationError(e.message, e.schema, e.instance) -940 -941 return records -942 -943 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: -944 """Get connected account information -945 -946 Args: -947 context: Execution context -948 -949 Returns: -950 IntegrationResult containing connected account data -951 -952 Raises: -953 ValidationError: If no connected account handler is registered or auth is invalid -954 """ -955 if not self._connected_account_handler: -956 raise ValidationError("No connected account handler registered") +772 auth = context.auth +773 has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict) +774 has_valid_auth_type = ( +775 isinstance(auth, dict) +776 and isinstance(auth.get("auth_type"), str) +777 and auth.get("auth_type") != "" +778 ) +779 if not has_valid_credentials or not has_valid_auth_type: +780 raise ValidationError( +781 'context.auth must be the platform auth envelope ' +782 '{"auth_type": ..., "credentials": {...}} with a non-empty ' +783 'auth_type; flat auth is not supported.', +784 source="auth", +785 ) +786 +787 valid_auth_types = {member.value for member in AuthType} +788 if auth["auth_type"] not in valid_auth_types: +789 raise ValidationError( +790 f'Unknown auth_type "{auth["auth_type"]}" in context.auth; ' +791 f'expected one of: {", ".join(sorted(valid_auth_types))}.', +792 source="auth", +793 ) +794 +795 auth_config = self.config.auth["fields"] +796 validator = Draft7Validator(auth_config) +797 errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path) +798 if errors: +799 message = "" +800 for error in errors: +801 message += f"{list(error.schema_path)}, {error.message},\n " +802 raise ValidationError(message, auth_config, context.auth["credentials"], source="auth") +803 +804 async def execute_action(self, +805 name: str, +806 inputs: Dict[str, Any], +807 context: ExecutionContext) -> IntegrationResult: +808 """Execute a registered action. +809 +810 Args: +811 name: Name of the action to execute +812 inputs: Action inputs +813 context: Execution context +814 +815 Returns: +816 IntegrationResult with action data (ResultType.ACTION), +817 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, +818 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. +819 """ +820 try: +821 if name not in self._action_handlers: +822 raise ValidationError(f"Action '{name}' not registered") +823 +824 # Validate inputs against action schema +825 action_config = self.config.actions[name] +826 validator = Draft7Validator(action_config.input_schema) +827 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) +828 if errors: +829 message = "" +830 for error in errors: +831 message += f"{list(error.schema_path)}, {error.message},\n " +832 raise ValidationError(message, action_config.input_schema, inputs, source="input") +833 +834 self._validate_auth(context) +835 +836 # Create handler instance and execute +837 handler = self._action_handlers[name]() +838 result = await handler.execute(inputs, context) +839 +840 # Handle ActionError - skip output schema validation +841 if isinstance(result, ActionError): +842 return IntegrationResult( +843 version=__version__, +844 type=ResultType.ACTION_ERROR, +845 result=result +846 ) +847 +848 # Validate that result is ActionResult +849 if not isinstance(result, ActionResult): +850 raise ValidationError( +851 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", +852 source="output" +853 ) +854 +855 # Validate output schema against the data inside ActionResult +856 validator = Draft7Validator(action_config.output_schema) +857 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) +858 if errors: +859 message = "" +860 for error in errors: +861 message += f"{list(error.schema_path)}, {error.message},\n " +862 raise ValidationError(message, action_config.output_schema, result.data, source="output") +863 +864 # Return IntegrationResult with ActionResult directly +865 return IntegrationResult( +866 version=__version__, +867 type=ResultType.ACTION, +868 result=result +869 ) +870 except ValidationError as e: +871 return IntegrationResult( +872 version=__version__, +873 type=ResultType.VALIDATION_ERROR, +874 result={ +875 'message': str(e), +876 'property': None, +877 'value': None, +878 'source': getattr(e, 'source', 'legacy') +879 } +880 ) +881 +882 async def execute_polling_trigger(self, +883 name: str, +884 inputs: Dict[str, Any], +885 last_poll_ts: Optional[str], +886 context: ExecutionContext) -> List[Dict[str, Any]]: +887 """Execute a registered polling trigger +888 +889 Args: +890 name: Name of the polling trigger to execute +891 inputs: Trigger inputs +892 last_poll_ts: Last poll timestamp +893 context: Execution context +894 +895 Returns: +896 List of records +897 +898 Raises: +899 ValidationError: If inputs or outputs don't match schema +900 """ +901 if name not in self._polling_handlers: +902 raise ValidationError(f"Polling trigger '{name}' not registered") +903 +904 # Validate trigger configuration +905 trigger_config = self.config.polling_triggers[name] +906 try: +907 validate(inputs, trigger_config.input_schema) +908 except Exception as e: +909 raise ValidationError(e.message, e.schema, e.instance) +910 +911 self._validate_auth(context) +912 +913 # Create handler instance and execute +914 handler = self._polling_handlers[name]() +915 records = await handler.poll(inputs, last_poll_ts, context) +916 # Validate each record +917 for record in records: +918 if "id" not in record: +919 raise ValidationError( +920 f"Polling trigger '{name}' returned record without required 'id' field") +921 if "data" not in record: +922 raise ValidationError( +923 f"Polling trigger '{name}' returned record without required 'data' field") +924 +925 # Validate record data against output schema +926 try: +927 validate(record["data"], trigger_config.output_schema) +928 except Exception as e: +929 raise ValidationError(e.message, e.schema, e.instance) +930 +931 return records +932 +933 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: +934 """Get connected account information +935 +936 Args: +937 context: Execution context +938 +939 Returns: +940 IntegrationResult containing connected account data +941 +942 Raises: +943 ValidationError: If no connected account handler is registered or auth is invalid +944 """ +945 if not self._connected_account_handler: +946 raise ValidationError("No connected account handler registered") +947 +948 self._validate_auth(context) +949 +950 handler = self._connected_account_handler() +951 account_info = await handler.get_account_info(context) +952 +953 if not isinstance(account_info, ConnectedAccountInfo): +954 raise ValidationError( +955 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" +956 ) 957 -958 if "fields" in self.config.auth: -959 auth_config = self.config.auth["fields"] -960 validator = Draft7Validator(auth_config) -961 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -962 if errors: -963 message = "" -964 for error in errors: -965 message += f"{list(error.schema_path)}, {error.message},\n " -966 raise ValidationError(message, auth_config, context.auth) -967 -968 handler = self._connected_account_handler() -969 previous_identity = (context._integration_name, context._integration_version) -970 context._set_integration_identity(self.config.name, self.config.version) -971 try: -972 account_info = await handler.get_account_info(context) -973 finally: -974 context._set_integration_identity(*previous_identity) -975 -976 if not isinstance(account_info, ConnectedAccountInfo): -977 raise ValidationError( -978 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" -979 ) -980 -981 # Return IntegrationResult with ConnectedAccountInfo object directly -982 return IntegrationResult( -983 version=__version__, -984 type=ResultType.CONNECTED_ACCOUNT, -985 result=account_info -986 ) +958 # Return IntegrationResult with ConnectedAccountInfo object directly +959 return IntegrationResult( +960 version=__version__, +961 type=ResultType.CONNECTED_ACCOUNT, +962 result=account_info +963 )Inherited Members
- + Integration(config: IntegrationConfig)-611 def __init__(self, config: IntegrationConfig): -612 self.config = config -613 """Integration configuration""" -614 self._action_handlers: Dict[str, Type[ActionHandler]] = {} -615 """Action handlers""" -616 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} -617 """Polling handlers""" -618 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None -619 """Connected account handler""" +- +571 def __init__(self, config: IntegrationConfig): +572 self.config = config +573 """Integration configuration""" +574 self._action_handlers: Dict[str, Type[ActionHandler]] = {} +575 """Action handlers""" +576 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} +577 """Polling handlers""" +578 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None +579 """Connected account handler"""-config - +- +@@ -4204,58 +4133,58 @@Integration configuration
Inherited Members
@classmethoddef - load( cls, config_path: Union[str, pathlib._local.Path] = None) -> Integration: + load( cls, config_path: str | pathlib.Path = None) -> Integration:621 @classmethod -622 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': -623 """Load an integration from its ``config.json``. -624 -625 Args: -626 config_path: Explicit path to ``config.json``. When omitted the -627 SDK resolves the path relative to its own package location, -628 which works when the SDK is vendored via -629 ``pip install --target dependencies``. Multi-file integrations -630 that use ``actions/`` sub-packages should pass an explicit path -631 (e.g. ``Integration.load("config.json")``). -632 -633 Returns: -634 A fully initialised ``Integration`` ready for handler registration. -635 -636 Raises: -637 ConfigurationError: If the file is missing or contains invalid JSON. -638 """ -639 if config_path is None: -640 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') -641 -642 config_path = Path(config_path) -643 -644 if not config_path.exists(): -645 raise ConfigurationError(f"Configuration file not found: {config_path}") -646 -647 try: -648 with open(config_path, 'r') as f: -649 config_data = json.load(f) -650 except json.JSONDecodeError as e: -651 raise ConfigurationError(f"Invalid JSON configuration: {e}") -652 -653 # Parse configuration sections -654 actions = cls._parse_actions(config_data.get("actions", {})) -655 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) -656 -657 config = IntegrationConfig( -658 name=config_data["name"], -659 version=config_data["version"], -660 description=config_data["description"], -661 auth=config_data.get("auth", {}), -662 actions=actions, -663 polling_triggers=polling_triggers -664 ) -665 -666 return cls(config) +@@ -4281,7 +4210,7 @@581 @classmethod +582 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': +583 """Load an integration from its ``config.json``. +584 +585 Args: +586 config_path: Explicit path to ``config.json``. When omitted the +587 SDK resolves the path relative to its own package location, +588 which works when the SDK is vendored via +589 ``pip install --target dependencies``. Multi-file integrations +590 that use ``actions/`` sub-packages should pass an explicit path +591 (e.g. ``Integration.load("config.json")``). +592 +593 Returns: +594 A fully initialised ``Integration`` ready for handler registration. +595 +596 Raises: +597 ConfigurationError: If the file is missing or contains invalid JSON. +598 """ +599 if config_path is None: +600 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') +601 +602 config_path = Path(config_path) +603 +604 if not config_path.exists(): +605 raise ConfigurationError(f"Configuration file not found: {config_path}") +606 +607 try: +608 with open(config_path, 'r') as f: +609 config_data = json.load(f) +610 except json.JSONDecodeError as e: +611 raise ConfigurationError(f"Invalid JSON configuration: {e}") +612 +613 # Parse configuration sections +614 actions = cls._parse_actions(config_data.get("actions", {})) +615 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) +616 +617 config = IntegrationConfig( +618 name=config_data["name"], +619 version=config_data["version"], +620 description=config_data["description"], +621 auth=config_data.get("auth", {}), +622 actions=actions, +623 polling_triggers=polling_triggers +624 ) +625 +626 return cls(config)Inherited Members
- + def action(self, name: str): @@ -4289,33 +4218,33 @@-Inherited Members
716 def action(self, name: str): -717 """Decorator to register an action handler. -718 -719 Args: -720 name: Name of the action to register -721 -722 Returns: -723 Decorator function -724 -725 Raises: -726 ConfigurationError: If action is not defined in config -727 -728 Example: -729 ```python -730 @integration.action("my_action") -731 class MyActionHandler(ActionHandler): -732 async def execute(self, inputs, context): -733 # Implementation -734 return result -735 ``` -736 """ -737 def decorator(handler_class: Type[ActionHandler]): -738 if name not in self.config.actions: -739 raise ConfigurationError(f"Action '{name}' not defined in config") -740 self._action_handlers[name] = handler_class -741 return handler_class -742 return decorator +@@ -4348,7 +4277,7 @@676 def action(self, name: str): +677 """Decorator to register an action handler. +678 +679 Args: +680 name: Name of the action to register +681 +682 Returns: +683 Decorator function +684 +685 Raises: +686 ConfigurationError: If action is not defined in config +687 +688 Example: +689 ```python +690 @integration.action("my_action") +691 class MyActionHandler(ActionHandler): +692 async def execute(self, inputs, context): +693 # Implementation +694 return result +695 ``` +696 """ +697 def decorator(handler_class: Type[ActionHandler]): +698 if name not in self.config.actions: +699 raise ConfigurationError(f"Action '{name}' not defined in config") +700 self._action_handlers[name] = handler_class +701 return handler_class +702 return decoratorInherited Members
- + def polling_trigger(self, name: str): @@ -4356,33 +4285,33 @@-Inherited Members
744 def polling_trigger(self, name: str): -745 """Decorator to register a polling trigger handler -746 -747 Args: -748 name: Name of the polling trigger to register -749 -750 Returns: -751 Decorator function -752 -753 Raises: -754 ConfigurationError: If polling trigger is not defined in config -755 -756 Example: -757 ```python -758 @integration.polling_trigger("my_polling_trigger") -759 class MyPollingTriggerHandler(PollingTriggerHandler): -760 async def poll(self, inputs, last_poll_ts, context): -761 # Implementation -762 return result -763 ``` -764 """ -765 def decorator(handler_class: Type[PollingTriggerHandler]): -766 if name not in self.config.polling_triggers: -767 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") -768 self._polling_handlers[name] = handler_class -769 return handler_class -770 return decorator +@@ -4415,7 +4344,7 @@704 def polling_trigger(self, name: str): +705 """Decorator to register a polling trigger handler +706 +707 Args: +708 name: Name of the polling trigger to register +709 +710 Returns: +711 Decorator function +712 +713 Raises: +714 ConfigurationError: If polling trigger is not defined in config +715 +716 Example: +717 ```python +718 @integration.polling_trigger("my_polling_trigger") +719 class MyPollingTriggerHandler(PollingTriggerHandler): +720 async def poll(self, inputs, last_poll_ts, context): +721 # Implementation +722 return result +723 ``` +724 """ +725 def decorator(handler_class: Type[PollingTriggerHandler]): +726 if name not in self.config.polling_triggers: +727 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") +728 self._polling_handlers[name] = handler_class +729 return handler_class +730 return decoratorInherited Members
- + def connected_account(self): @@ -4423,25 +4352,25 @@-Inherited Members
772 def connected_account(self): -773 """Decorator to register a connected account handler -774 -775 Returns: -776 Decorator function -777 -778 Example: -779 ```python -780 @integration.connected_account() -781 class MyConnectedAccountHandler(ConnectedAccountHandler): -782 async def get_account_info(self, context): -783 # Implementation -784 return {"email": "user@example.com", "name": "John Doe"} -785 ``` -786 """ -787 def decorator(handler_class: Type[ConnectedAccountHandler]): -788 self._connected_account_handler = handler_class -789 return handler_class -790 return decorator +@@ -4468,7 +4397,7 @@732 def connected_account(self): +733 """Decorator to register a connected account handler +734 +735 Returns: +736 Decorator function +737 +738 Example: +739 ```python +740 @integration.connected_account() +741 class MyConnectedAccountHandler(ConnectedAccountHandler): +742 async def get_account_info(self, context): +743 # Implementation +744 return {"email": "user@example.com", "name": "John Doe"} +745 ``` +746 """ +747 def decorator(handler_class: Type[ConnectedAccountHandler]): +748 self._connected_account_handler = handler_class +749 return handler_class +750 return decoratorInherited Members
- + async def execute_action( self, name: str, inputs: Dict[str, Any], context: ExecutionContext) -> IntegrationResult: @@ -4476,96 +4405,83 @@-Inherited Members
792 async def execute_action(self, -793 name: str, -794 inputs: Dict[str, Any], -795 context: ExecutionContext) -> IntegrationResult: -796 """Execute a registered action. -797 -798 Args: -799 name: Name of the action to execute -800 inputs: Action inputs -801 context: Execution context -802 -803 Returns: -804 IntegrationResult with action data (ResultType.ACTION), -805 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, -806 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. -807 """ -808 try: -809 if name not in self._action_handlers: -810 raise ValidationError(f"Action '{name}' not registered") -811 -812 # Validate inputs against action schema -813 action_config = self.config.actions[name] -814 validator = Draft7Validator(action_config.input_schema) -815 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) -816 if errors: -817 message = "" -818 for error in errors: -819 message += f"{list(error.schema_path)}, {error.message},\n " -820 raise ValidationError(message, action_config.input_schema, inputs, source="input") -821 -822 if "fields" in self.config.auth: -823 auth_config = self.config.auth["fields"] -824 validator = Draft7Validator(auth_config) -825 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -826 if errors: -827 message = "" -828 for error in errors: -829 message += f"{list(error.schema_path)}, {error.message},\n " -830 raise ValidationError(message, auth_config, context.auth, source="input") -831 -832 # Create handler instance and execute -833 handler = self._action_handlers[name]() -834 previous_identity = (context._integration_name, context._integration_version) -835 context._set_integration_identity(self.config.name, self.config.version) -836 try: -837 result = await handler.execute(inputs, context) -838 finally: -839 context._set_integration_identity(*previous_identity) -840 -841 # Handle ActionError - skip output schema validation -842 if isinstance(result, ActionError): -843 return IntegrationResult( -844 version=__version__, -845 type=ResultType.ACTION_ERROR, -846 result=result -847 ) -848 -849 # Validate that result is ActionResult -850 if not isinstance(result, ActionResult): -851 raise ValidationError( -852 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", -853 source="output" -854 ) -855 -856 # Validate output schema against the data inside ActionResult -857 validator = Draft7Validator(action_config.output_schema) -858 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) -859 if errors: -860 message = "" -861 for error in errors: -862 message += f"{list(error.schema_path)}, {error.message},\n " -863 raise ValidationError(message, action_config.output_schema, result.data, source="output") -864 -865 # Return IntegrationResult with ActionResult directly -866 return IntegrationResult( -867 version=__version__, -868 type=ResultType.ACTION, -869 result=result -870 ) -871 except ValidationError as e: -872 return IntegrationResult( -873 version=__version__, -874 type=ResultType.VALIDATION_ERROR, -875 result={ -876 'message': str(e), -877 'property': None, -878 'value': None, -879 'source': getattr(e, 'source', 'legacy') -880 } -881 ) +@@ -4587,73 +4503,64 @@804 async def execute_action(self, +805 name: str, +806 inputs: Dict[str, Any], +807 context: ExecutionContext) -> IntegrationResult: +808 """Execute a registered action. +809 +810 Args: +811 name: Name of the action to execute +812 inputs: Action inputs +813 context: Execution context +814 +815 Returns: +816 IntegrationResult with action data (ResultType.ACTION), +817 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, +818 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. +819 """ +820 try: +821 if name not in self._action_handlers: +822 raise ValidationError(f"Action '{name}' not registered") +823 +824 # Validate inputs against action schema +825 action_config = self.config.actions[name] +826 validator = Draft7Validator(action_config.input_schema) +827 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) +828 if errors: +829 message = "" +830 for error in errors: +831 message += f"{list(error.schema_path)}, {error.message},\n " +832 raise ValidationError(message, action_config.input_schema, inputs, source="input") +833 +834 self._validate_auth(context) +835 +836 # Create handler instance and execute +837 handler = self._action_handlers[name]() +838 result = await handler.execute(inputs, context) +839 +840 # Handle ActionError - skip output schema validation +841 if isinstance(result, ActionError): +842 return IntegrationResult( +843 version=__version__, +844 type=ResultType.ACTION_ERROR, +845 result=result +846 ) +847 +848 # Validate that result is ActionResult +849 if not isinstance(result, ActionResult): +850 raise ValidationError( +851 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", +852 source="output" +853 ) +854 +855 # Validate output schema against the data inside ActionResult +856 validator = Draft7Validator(action_config.output_schema) +857 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) +858 if errors: +859 message = "" +860 for error in errors: +861 message += f"{list(error.schema_path)}, {error.message},\n " +862 raise ValidationError(message, action_config.output_schema, result.data, source="output") +863 +864 # Return IntegrationResult with ActionResult directly +865 return IntegrationResult( +866 version=__version__, +867 type=ResultType.ACTION, +868 result=result +869 ) +870 except ValidationError as e: +871 return IntegrationResult( +872 version=__version__, +873 type=ResultType.VALIDATION_ERROR, +874 result={ +875 'message': str(e), +876 'property': None, +877 'value': None, +878 'source': getattr(e, 'source', 'legacy') +879 } +880 )Inherited Members
- + async def - execute_polling_trigger( self, name: str, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: ExecutionContext) -> List[Dict[str, Any]]: + execute_polling_trigger( self, name: str, inputs: Dict[str, Any], last_poll_ts: str | None, context: ExecutionContext) -> List[Dict[str, Any]]:-883 async def execute_polling_trigger(self, -884 name: str, -885 inputs: Dict[str, Any], -886 last_poll_ts: Optional[str], -887 context: ExecutionContext) -> List[Dict[str, Any]]: -888 """Execute a registered polling trigger -889 -890 Args: -891 name: Name of the polling trigger to execute -892 inputs: Trigger inputs -893 last_poll_ts: Last poll timestamp -894 context: Execution context -895 -896 Returns: -897 List of records -898 -899 Raises: -900 ValidationError: If inputs or outputs don't match schema -901 """ -902 if name not in self._polling_handlers: -903 raise ValidationError(f"Polling trigger '{name}' not registered") -904 -905 # Validate trigger configuration -906 trigger_config = self.config.polling_triggers[name] -907 try: -908 validate(inputs, trigger_config.input_schema) -909 except Exception as e: -910 raise ValidationError(e.message, e.schema, e.instance) -911 -912 try: -913 auth_config = self.config.auth["fields"] -914 validate(context.auth, auth_config) -915 except Exception as e: -916 raise ValidationError(e.message, e.schema, e.instance) -917 -918 # Create handler instance and execute -919 handler = self._polling_handlers[name]() -920 previous_identity = (context._integration_name, context._integration_version) -921 context._set_integration_identity(self.config.name, self.config.version) -922 try: -923 records = await handler.poll(inputs, last_poll_ts, context) -924 finally: -925 context._set_integration_identity(*previous_identity) -926 # Validate each record -927 for record in records: -928 if "id" not in record: -929 raise ValidationError( -930 f"Polling trigger '{name}' returned record without required 'id' field") -931 if "data" not in record: -932 raise ValidationError( -933 f"Polling trigger '{name}' returned record without required 'data' field") -934 -935 # Validate record data against output schema -936 try: -937 validate(record["data"], trigger_config.output_schema) -938 except Exception as e: -939 raise ValidationError(e.message, e.schema, e.instance) -940 -941 return records +@@ -4677,7 +4584,7 @@882 async def execute_polling_trigger(self, +883 name: str, +884 inputs: Dict[str, Any], +885 last_poll_ts: Optional[str], +886 context: ExecutionContext) -> List[Dict[str, Any]]: +887 """Execute a registered polling trigger +888 +889 Args: +890 name: Name of the polling trigger to execute +891 inputs: Trigger inputs +892 last_poll_ts: Last poll timestamp +893 context: Execution context +894 +895 Returns: +896 List of records +897 +898 Raises: +899 ValidationError: If inputs or outputs don't match schema +900 """ +901 if name not in self._polling_handlers: +902 raise ValidationError(f"Polling trigger '{name}' not registered") +903 +904 # Validate trigger configuration +905 trigger_config = self.config.polling_triggers[name] +906 try: +907 validate(inputs, trigger_config.input_schema) +908 except Exception as e: +909 raise ValidationError(e.message, e.schema, e.instance) +910 +911 self._validate_auth(context) +912 +913 # Create handler instance and execute +914 handler = self._polling_handlers[name]() +915 records = await handler.poll(inputs, last_poll_ts, context) +916 # Validate each record +917 for record in records: +918 if "id" not in record: +919 raise ValidationError( +920 f"Polling trigger '{name}' returned record without required 'id' field") +921 if "data" not in record: +922 raise ValidationError( +923 f"Polling trigger '{name}' returned record without required 'data' field") +924 +925 # Validate record data against output schema +926 try: +927 validate(record["data"], trigger_config.output_schema) +928 except Exception as e: +929 raise ValidationError(e.message, e.schema, e.instance) +930 +931 return recordsInherited Members
- + async def get_connected_account( self, context: ExecutionContext) -> IntegrationResult: @@ -4685,50 +4592,37 @@-Inherited Members
943 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: -944 """Get connected account information -945 -946 Args: -947 context: Execution context -948 -949 Returns: -950 IntegrationResult containing connected account data -951 -952 Raises: -953 ValidationError: If no connected account handler is registered or auth is invalid -954 """ -955 if not self._connected_account_handler: -956 raise ValidationError("No connected account handler registered") +@@ -4930,4 +4824,4 @@933 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: +934 """Get connected account information +935 +936 Args: +937 context: Execution context +938 +939 Returns: +940 IntegrationResult containing connected account data +941 +942 Raises: +943 ValidationError: If no connected account handler is registered or auth is invalid +944 """ +945 if not self._connected_account_handler: +946 raise ValidationError("No connected account handler registered") +947 +948 self._validate_auth(context) +949 +950 handler = self._connected_account_handler() +951 account_info = await handler.get_account_info(context) +952 +953 if not isinstance(account_info, ConnectedAccountInfo): +954 raise ValidationError( +955 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" +956 ) 957 -958 if "fields" in self.config.auth: -959 auth_config = self.config.auth["fields"] -960 validator = Draft7Validator(auth_config) -961 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) -962 if errors: -963 message = "" -964 for error in errors: -965 message += f"{list(error.schema_path)}, {error.message},\n " -966 raise ValidationError(message, auth_config, context.auth) -967 -968 handler = self._connected_account_handler() -969 previous_identity = (context._integration_name, context._integration_version) -970 context._set_integration_identity(self.config.name, self.config.version) -971 try: -972 account_info = await handler.get_account_info(context) -973 finally: -974 context._set_integration_identity(*previous_identity) -975 -976 if not isinstance(account_info, ConnectedAccountInfo): -977 raise ValidationError( -978 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" -979 ) -980 -981 # Return IntegrationResult with ConnectedAccountInfo object directly -982 return IntegrationResult( -983 version=__version__, -984 type=ResultType.CONNECTED_ACCOUNT, -985 result=account_info -986 ) +958 # Return IntegrationResult with ConnectedAccountInfo object directly +959 return IntegrationResult( +960 version=__version__, +961 type=ResultType.CONNECTED_ACCOUNT, +962 result=account_info +963 )Inherited Members
} });