autohive_integrations_sdk.integration

Autohive Integrations SDK — core module.

Provides the building blocks for creating Autohive integrations:

Typical usage::

from autohive_integrations_sdk import Integration, ActionHandler, ActionResult, ExecutionContext

integration = Integration.load()

@integration.action("my_action")
class MyAction(ActionHandler):
    async def execute(self, inputs, context):
        response = await context.fetch("https://api.example.com/resource")
        return ActionResult(data=response.data)
  1"""Autohive Integrations SDK — core module.
  2
  3Provides the building blocks for creating Autohive integrations:
  4
  5- `Integration` — load config and register action/trigger/connected-account handlers
  6- `ExecutionContext` — authenticated HTTP client passed to every handler
  7- `ActionHandler` — base class for action implementations (return `ActionResult`)
  8- `ConnectedAccountHandler` — base class for connected-account lookups (return `ConnectedAccountInfo`)
  9- `ActionResult` — standard return type wrapping action output data and optional billing cost
 10- `ActionError` — return type for expected application-level errors (bypasses output schema validation)
 11- `FetchResponse` — response object from ``context.fetch()`` with ``.status``, ``.headers``, and ``.data``
 12- `ConnectedAccountInfo` — structured account info returned by connected-account handlers
 13- `HTTPError` / `RateLimitError` — exceptions raised by ``context.fetch()`` for non-2xx responses
 14
 15Typical usage::
 16
 17    from autohive_integrations_sdk import Integration, ActionHandler, ActionResult, ExecutionContext
 18
 19    integration = Integration.load()
 20
 21    @integration.action("my_action")
 22    class MyAction(ActionHandler):
 23        async def execute(self, inputs, context):
 24            response = await context.fetch("https://api.example.com/resource")
 25            return ActionResult(data=response.data)
 26"""
 27
 28# Standard Library Imports
 29from abc import ABC, abstractmethod
 30import asyncio
 31from dataclasses import dataclass, field, asdict
 32from datetime import timedelta
 33from enum import Enum
 34import json
 35import json as jsonX  # Keep alias to avoid conflict with 'json' parameter in fetch
 36import logging
 37import os
 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# Local Imports
 49from autohive_integrations_sdk import __version__
 50
 51
 52# ---- Type Definitions ----
 53T = TypeVar('T')
 54
 55# ---- Auth Types ----
 56class AuthType(Enum):
 57    """Authentication strategy used by an integration.
 58
 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    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 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
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        # 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        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                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        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            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        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        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        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        # Return IntegrationResult with ConnectedAccountInfo object directly
958        return IntegrationResult(
959            version=__version__,
960            type=ResultType.CONNECTED_ACCOUNT,
961            result=account_info
962        )
class AuthType(enum.Enum):
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"

Authentication strategy used by an integration.

The platform stores the auth type alongside credentials and passes both to ExecutionContext. context.fetch() uses the type to decide whether to auto-inject an Authorization header.

Members: PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the token lifecycle and injects Bearer <access_token> automatically. PlatformTeams: Platform-managed Microsoft Teams auth. ApiKey: A single API key provided by the user. Basic: Username/password (HTTP Basic) credentials. Custom: Free-form credential fields defined by the integration's config.json auth schema. The integration is responsible for reading individual fields from context.auth.

PlatformOauth2 = <AuthType.PlatformOauth2: 'PlatformOauth2'>
PlatformTeams = <AuthType.PlatformTeams: 'PlatformTeams'>
ApiKey = <AuthType.ApiKey: 'ApiKey'>
Basic = <AuthType.Basic: 'Basic'>
Custom = <AuthType.Custom: 'Custom'>
class ResultType(enum.Enum):
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"

Type of result being returned

ACTION = <ResultType.ACTION: 'action'>
ACTION_ERROR = <ResultType.ACTION_ERROR: 'action_error'>
CONNECTED_ACCOUNT = <ResultType.CONNECTED_ACCOUNT: 'connected_account'>
ERROR = <ResultType.ERROR: 'error'>
VALIDATION_ERROR = <ResultType.VALIDATION_ERROR: 'validation_error'>
class ValidationError(builtins.Exception):
 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)

Raised when SDK validation fails.

This covers several cases:

  • Action inputs don't match the input_schema in config.json
  • Action outputs don't match the output_schema
  • Auth credentials don't match the auth.fields schema
  • An action handler returns something other than ActionResult
  • A handler name isn't registered
ValidationError( message: str, schema: str = None, inputs: str = None, source: str = 'legacy')
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

The schema that failed validation

inputs

The data that failed validation

message

The error message

source

Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)

class ConfigurationError(builtins.Exception):
111class ConfigurationError(Exception):
112    """Raised when integration configuration is invalid"""
113    pass

Raised when integration configuration is invalid

class HTTPError(builtins.Exception):
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}")

Raised by ExecutionContext.fetch() for non-2xx HTTP responses (except 429).

HTTPError(status: int, message: str, response_data: Any = None)
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}")
status

Status code

message

Error message

response_data

Response data

class RateLimitError(HTTPError):
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)

Raised by ExecutionContext.fetch() on HTTP 429 (Too Many Requests).

Attributes: retry_after: Seconds to wait before retrying, taken from the Retry-After response header (defaults to 60 if absent).

RateLimitError(retry_after: int, *args, **kwargs)
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)
retry_after

Seconds to wait before retrying.

@dataclass
class FetchResponse:
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: Any

Response object returned by ExecutionContext.fetch().

Wraps the full HTTP response so callers can inspect status codes and headers in addition to the parsed body.

Attributes: status: HTTP status code (e.g. 200, 201). headers: Response headers as a plain dict. data: Parsed JSON (dict/list) when the response is application/json, otherwise the raw response text. None for empty 200/201/204 responses.

FetchResponse(status: int, headers: Dict[str, str], data: Any)
status: int
headers: Dict[str, str]
data: Any
@dataclass
class ActionResult:
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] = None

Result returned by action handlers.

This class encapsulates the data returned by an action along with optional billing information for cost tracking.

Args: data: The actual result data from the action cost_usd: Optional USD cost for billing purposes

Example:

return ActionResult(
    data={"message": "Success", "result": 42},
    cost_usd=0.05
)
ActionResult(data: Any, cost_usd: float | None = None)
data: Any
cost_usd: float | None = None
@dataclass
class ActionError:
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] = None

Error result returned by action handlers for expected/application-level errors.

When returned from an action handler, output schema validation is skipped and the error is returned to the caller as a ResultType.ERROR result.

Args: message: Human-readable error message cost_usd: Optional USD cost incurred before the error occurred

Example:

return ActionError(
    message="User not found",
    cost_usd=0.01
)
ActionError(message: str, cost_usd: float | None = None)
message: str
cost_usd: float | None = None
@dataclass
class ConnectedAccountInfo:
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] = None

Account metadata returned by a ConnectedAccountHandler.

The platform calls the connected-account handler after a user links their external account. The returned info is displayed in the Autohive UI (avatar, name, email, etc.).

All fields are optional — populate whichever ones the external API provides.

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)
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
@dataclass
class IntegrationResult:
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]

Result format sent from lambda wrapper to backend.

This class represents the standardized format that the lambda wrapper sends to the Autohive backend, including SDK version and type-specific data.

Args: version: SDK version (auto-populated) type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) result: The result object - ActionResult for actions, ActionError for application-level action errors, or ConnectedAccountInfo for connected accounts. The lambda wrapper serializes these to dicts using asdict().

Note: This type is returned by Integration methods and serialized by the lambda wrapper. Integration developers should use ActionResult for action handlers and ActionError for expected error conditions.

IntegrationResult( version: str, type: ResultType, result: ActionResult | ActionError | ConnectedAccountInfo)
version: str
type: ResultType
@dataclass
class Parameter:
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 = None

Definition of a parameter

Parameter( name: str, type: str, description: str, enum: List[str] | None = None, required: bool = True, default: Any = None)
name: str
type: str
description: str
enum: List[str] | None = None
required: bool = True
default: Any = None
@dataclass
class SchemaDefinition:
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]] = None

Base class for components that have input/output schemas

SchemaDefinition( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None)
name: str
description: str
input_schema: List[Parameter]
output_schema: Dict[str, Any] | None = None
@dataclass
class Action(SchemaDefinition):
263@dataclass
264class Action(SchemaDefinition):
265    """Empty dataclass that inherits from SchemaDefinition"""
266    pass

Empty dataclass that inherits from SchemaDefinition

Action( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None)
@dataclass
class PollingTrigger(SchemaDefinition):
268@dataclass
269class PollingTrigger(SchemaDefinition):
270    """Definition of a polling trigger"""
271    polling_interval: timedelta = field(default_factory=timedelta)

Definition of a polling trigger

PollingTrigger( name: str, description: str, input_schema: List[Parameter], output_schema: Dict[str, Any] | None = None, polling_interval: datetime.timedelta = <factory>)
polling_interval: datetime.timedelta
@dataclass
class IntegrationConfig:
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]

Configuration for an integration

IntegrationConfig( name: str, version: str, description: str, auth: Dict[str, Any], actions: Dict[str, Action], polling_triggers: Dict[str, PollingTrigger])
name: str
version: str
description: str
auth: Dict[str, Any]
actions: Dict[str, Action]
polling_triggers: Dict[str, PollingTrigger]
class ActionHandler(abc.ABC):
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        pass

Base class for action handlers.

Subclass this and implement execute() to handle a specific action. Register it with the @integration.action("action_name") decorator.

Example::

@integration.action("get_user")
class GetUser(ActionHandler):
    async def execute(self, inputs, context):
        user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data
        return ActionResult(data=user)
@abstractmethod
async def execute( self, inputs: Dict[str, Any], context: ExecutionContext) -> Any:
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        pass

Run the action logic.

Args: inputs: Validated action inputs matching the input_schema from config.json. context: Execution context providing fetch(), auth, and logging.

Returns: An ActionResult containing the output data and optional cost_usd.

class PollingTriggerHandler(abc.ABC):
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        pass

Base class for polling trigger handlers

@abstractmethod
async def poll( self, inputs: Dict[str, Any], last_poll_ts: str | None, context: ExecutionContext) -> List[Dict[str, Any]]:
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        pass

Execute the polling trigger

class ConnectedAccountHandler(abc.ABC):
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        pass

Base class for connected-account handlers.

The platform calls this after a user links their external account. The returned ConnectedAccountInfo is shown in the Autohive UI.

Register with the @integration.connected_account() decorator.

Example::

@integration.connected_account()
class MyAccountHandler(ConnectedAccountHandler):
    async def get_account_info(self, context):
        me = (await context.fetch("https://api.example.com/me")).data
        return ConnectedAccountInfo(
            email=me["email"],
            first_name=me["first_name"],
            last_name=me["last_name"],
        )
@abstractmethod
async def get_account_info( self, context: ExecutionContext) -> ConnectedAccountInfo:
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        pass

Fetch account metadata from the external service.

For platform OAuth integrations, context.fetch() auto-injects the Bearer token — no manual auth handling needed.

Returns: A ConnectedAccountInfo with whichever fields the API provides.

class ExecutionContext:
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        # 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        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                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            raise

Context provided to integration handlers for making authenticated HTTP requests.

Manages an aiohttp session with automatic retries, error handling, and optional Bearer-token injection for platform OAuth integrations.

Use as an async context manager::

async with ExecutionContext(auth=auth) as context:
    result = await integration.execute_action("my_action", inputs, context)

Args: auth: Authentication data. This is always the platform auth envelope {"auth_type": "...", "credentials": {...}}. The credentials object matches the auth.fields schema in config.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 via context.auth["credentials"] (for example context.auth["credentials"].get("api_key", "")); strict validation guarantees this shape exists before a handler runs. request_config: Override default max_retries (3) and timeout (30 s). metadata: Arbitrary metadata forwarded to handlers. logger: Custom logger; falls back to logging.getLogger(__name__).

ExecutionContext( auth: Dict[str, Any] = {}, request_config: Dict[str, Any] | None = None, metadata: Dict[str, Any] | None = None, logger: logging.Logger | None = 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] = None
auth

Authentication configuration

config

Request configuration

metadata

Additional metadata

logger

Logger instance

async def 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:
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        # 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        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                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            raise

Make an HTTP request with automatic retries and error handling.

For platform OAuth integrations (auth_type == "PlatformOauth2"), a Bearer token is auto-injected from auth.credentials.access_token unless an Authorization header is explicitly provided.

Retries up to max_retries (default 3) on transient network errors with exponential back-off. HTTP 429 responses raise RateLimitError immediately (no automatic retry).

Args: url: The URL to request. method: HTTP method ("GET", "POST", "PUT", etc.). params: Query parameters appended to the URL. Nested dicts/lists are JSON-serialized automatically. data: Raw request body. Encoding depends on content_type. json: JSON-serializable payload. Sets content_type to application/json automatically. headers: Additional HTTP headers. Merged after any auto-injected auth header, so an explicit Authorization takes precedence. content_type: Content-Type header value. timeout: Per-request timeout in seconds (overrides request_config). retry_count: Internal — current retry attempt number.

Returns: A FetchResponse containing the HTTP status code, response headers, and parsed body data.

Raises: RateLimitError: On HTTP 429 with the Retry-After value. HTTPError: On any other non-2xx status.

class Integration:
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        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            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        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        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        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        # Return IntegrationResult with ConnectedAccountInfo object directly
959        return IntegrationResult(
960            version=__version__,
961            type=ResultType.CONNECTED_ACCOUNT,
962            result=account_info
963        )

Base integration class with handler registration and execution.

This class manages the integration configuration, handler registration, and provides methods to execute actions and triggers.

Args: config: Integration configuration

Attributes: config: Integration configuration

Integration(config: IntegrationConfig)
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

Integration configuration

@classmethod
def load( cls, config_path: str | pathlib.Path = None) -> Integration:
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)

Load an integration from its config.json.

Args: config_path: Explicit path to config.json. When omitted the SDK resolves the path relative to its own package location, which works when the SDK is vendored via pip install --target dependencies. Multi-file integrations that use actions/ sub-packages should pass an explicit path (e.g. Integration.load("config.json")).

Returns: A fully initialised Integration ready for handler registration.

Raises: ConfigurationError: If the file is missing or contains invalid JSON.

def action(self, name: str):
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

Decorator to register an action handler.

Args: name: Name of the action to register

Returns: Decorator function

Raises: ConfigurationError: If action is not defined in config

Example:

@integration.action("my_action")
class MyActionHandler(ActionHandler):
    async def execute(self, inputs, context):
        # Implementation
        return result
def polling_trigger(self, name: str):
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 decorator

Decorator to register a polling trigger handler

Args: name: Name of the polling trigger to register

Returns: Decorator function

Raises: ConfigurationError: If polling trigger is not defined in config

Example:

@integration.polling_trigger("my_polling_trigger")
class MyPollingTriggerHandler(PollingTriggerHandler):
    async def poll(self, inputs, last_poll_ts, context):
        # Implementation
        return result
def connected_account(self):
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

Decorator to register a connected account handler

Returns: Decorator function

Example:

@integration.connected_account()
class MyConnectedAccountHandler(ConnectedAccountHandler):
    async def get_account_info(self, context):
        # Implementation
        return {"email": "user@example.com", "name": "John Doe"}
async def execute_action( self, name: str, inputs: Dict[str, Any], context: ExecutionContext) -> IntegrationResult:
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            )

Execute a registered action.

Args: name: Name of the action to execute inputs: Action inputs context: Execution context

Returns: IntegrationResult with action data (ResultType.ACTION), action error (ResultType.ACTION_ERROR) if the handler returned ActionError, or validation error (ResultType.VALIDATION_ERROR) if schema validation fails.

async def execute_polling_trigger( self, name: str, inputs: Dict[str, Any], last_poll_ts: str | None, context: ExecutionContext) -> List[Dict[str, Any]]:
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

Execute a registered polling trigger

Args: name: Name of the polling trigger to execute inputs: Trigger inputs last_poll_ts: Last poll timestamp context: Execution context

Returns: List of records

Raises: ValidationError: If inputs or outputs don't match schema

async def get_connected_account( self, context: ExecutionContext) -> IntegrationResult:
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        # Return IntegrationResult with ConnectedAccountInfo object directly
959        return IntegrationResult(
960            version=__version__,
961            type=ResultType.CONNECTED_ACCOUNT,
962            result=account_info
963        )

Get connected account information

Args: context: Execution context

Returns: IntegrationResult containing connected account data

Raises: ValidationError: If no connected account handler is registered or auth is invalid