lambda-preflight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ """IAM policy evaluator.
2
+
3
+ Implements AWS's documented identity-policy decision flow:
4
+
5
+ explicit Deny -> wins, always
6
+ else an Allow -> required to permit
7
+ else -> implicit deny
8
+
9
+ When a permission boundary is present, an action must be allowed by *both*
10
+ the identity policies and the boundary (the intersection), matching AWS.
11
+
12
+ Strict mode turns "I cannot faithfully evaluate this" into an error rather
13
+ than a silent pass — the safe default for a tool whose promise is fidelity.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from .conditions import action_matches, evaluate_condition, resource_matches
22
+ from .model import PolicySet, Statement
23
+
24
+
25
+ class AccessDenied(Exception):
26
+ """Raised when the evaluator denies a simulated call."""
27
+
28
+
29
+ class CannotEvaluate(Exception):
30
+ """Raised in strict mode when a decision depends on unsupported logic."""
31
+
32
+
33
+ @dataclass
34
+ class Decision:
35
+ """Result of an authorization check.
36
+
37
+ ``allowed`` is the effective decision. ``reason`` explains it.
38
+ ``unevaluable`` lists condition operators that could not be assessed;
39
+ in strict mode a non-empty list turns into CannotEvaluate.
40
+ """
41
+
42
+ allowed: bool
43
+ reason: str
44
+ unevaluable: List[str]
45
+ matched_sid: Optional[str] = None
46
+
47
+
48
+ class PolicyEvaluator:
49
+ """Evaluate simulated AWS calls against a local PolicySet."""
50
+
51
+ def __init__(
52
+ self,
53
+ policies: PolicySet,
54
+ strict: bool = False,
55
+ boundary: Optional[List[Statement]] = None,
56
+ ) -> None:
57
+ self.policies = policies
58
+ self.strict = strict
59
+ # Explicit boundary arg overrides whatever is on the PolicySet.
60
+ self.boundary = (
61
+ boundary if boundary is not None else policies.boundary
62
+ )
63
+
64
+ def evaluate(
65
+ self,
66
+ action: str,
67
+ resource: str = "*",
68
+ context: Optional[Dict[str, Any]] = None,
69
+ ) -> Decision:
70
+ """Return the Decision for ``action`` on ``resource``."""
71
+ context = context or {}
72
+ identity = self._evaluate_statements(
73
+ self.policies.statements, action, resource, context
74
+ )
75
+
76
+ # Explicit deny in identity policies wins immediately.
77
+ if identity["explicit_deny"]:
78
+ return Decision(
79
+ allowed=False,
80
+ reason=f"Explicit Deny on {action}",
81
+ unevaluable=identity["unevaluable"],
82
+ matched_sid=identity["deny_sid"],
83
+ )
84
+
85
+ allowed = identity["allow"]
86
+ reason = (
87
+ f"Allowed by {identity['allow_sid'] or 'identity policy'}"
88
+ if allowed
89
+ else f"Implicit deny: no matching Allow for {action}"
90
+ )
91
+
92
+ # Permission boundary: must also allow (intersection semantics).
93
+ if self.boundary:
94
+ b = self._evaluate_statements(
95
+ self.boundary, action, resource, context
96
+ )
97
+ if b["explicit_deny"]:
98
+ allowed = False
99
+ reason = f"Denied by permission boundary on {action}"
100
+ elif not b["allow"]:
101
+ allowed = False
102
+ reason = f"Not permitted by permission boundary: {action}"
103
+ identity["unevaluable"].extend(b["unevaluable"])
104
+
105
+ decision = Decision(
106
+ allowed=allowed,
107
+ reason=reason,
108
+ unevaluable=identity["unevaluable"],
109
+ matched_sid=identity["allow_sid"],
110
+ )
111
+
112
+ if self.strict and decision.unevaluable:
113
+ raise CannotEvaluate(
114
+ f"Cannot faithfully evaluate {action} on {resource}: "
115
+ f"unsupported condition operators "
116
+ f"{sorted(set(decision.unevaluable))}"
117
+ )
118
+ return decision
119
+
120
+ def authorize(
121
+ self,
122
+ action: str,
123
+ resource: str = "*",
124
+ context: Optional[Dict[str, Any]] = None,
125
+ ) -> None:
126
+ """Raise AccessDenied if the call is not allowed; else return None."""
127
+ decision = self.evaluate(action, resource, context)
128
+ if not decision.allowed:
129
+ raise AccessDenied(decision.reason)
130
+
131
+ def _evaluate_statements(
132
+ self,
133
+ statements: List[Statement],
134
+ action: str,
135
+ resource: str,
136
+ context: Dict[str, Any],
137
+ ) -> Dict[str, Any]:
138
+ """Scan statements once, collecting allow/deny signals."""
139
+ allow = False
140
+ allow_sid: Optional[str] = None
141
+ explicit_deny = False
142
+ deny_sid: Optional[str] = None
143
+ unevaluable: List[str] = []
144
+
145
+ for stmt in statements:
146
+ if not self._action_in(stmt, action):
147
+ continue
148
+ if not self._resource_in(stmt, resource):
149
+ continue
150
+
151
+ passed, uneval = evaluate_condition(stmt.conditions, context)
152
+ unevaluable.extend(uneval)
153
+ if not passed:
154
+ continue
155
+
156
+ if stmt.effect == "Deny":
157
+ explicit_deny = True
158
+ deny_sid = stmt.sid
159
+ elif stmt.effect == "Allow":
160
+ allow = True
161
+ allow_sid = allow_sid or stmt.sid
162
+
163
+ return {
164
+ "allow": allow,
165
+ "allow_sid": allow_sid,
166
+ "explicit_deny": explicit_deny,
167
+ "deny_sid": deny_sid,
168
+ "unevaluable": unevaluable,
169
+ }
170
+
171
+ @staticmethod
172
+ def _action_in(stmt: Statement, action: str) -> bool:
173
+ return any(action_matches(p, action) for p in stmt.actions)
174
+
175
+ @staticmethod
176
+ def _resource_in(stmt: Statement, resource: str) -> bool:
177
+ return any(resource_matches(p, resource) for p in stmt.resources)
@@ -0,0 +1,101 @@
1
+ """boto3 intercept hook.
2
+
3
+ Hooks botocore's event system so that AWS SDK calls made *inside* a handler
4
+ are authorized against the local evaluator before they go anywhere. This is
5
+ cleaner than asking developers to annotate every call: it maps the botocore
6
+ operation to an IAM action and checks it, raising AccessDenied locally when
7
+ the role's real policy would not permit it.
8
+
9
+ Best-effort action mapping: botocore gives ``service`` and an operation name
10
+ like ``PutObject``; the IAM action is ``s3:PutObject``. A small override map
11
+ handles the cases where service id and IAM prefix differ.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from contextlib import contextmanager
17
+ from typing import Any, Dict, Iterator, Optional
18
+
19
+ from .evaluator import PolicyEvaluator
20
+
21
+ # botocore service id -> IAM action prefix, where they diverge.
22
+ _SERVICE_PREFIX = {
23
+ "dynamodb": "dynamodb",
24
+ "s3": "s3",
25
+ "sqs": "sqs",
26
+ "sns": "sns",
27
+ "lambda": "lambda",
28
+ "states": "states",
29
+ "secretsmanager": "secretsmanager",
30
+ "ssm": "ssm",
31
+ "events": "events",
32
+ "sts": "sts",
33
+ "cloudwatch": "cloudwatch",
34
+ "logs": "logs",
35
+ }
36
+
37
+
38
+ def operation_to_action(service_id: str, operation_name: str) -> str:
39
+ """Map a botocore operation to an IAM action string."""
40
+ prefix = _SERVICE_PREFIX.get(service_id, service_id)
41
+ return f"{prefix}:{operation_name}"
42
+
43
+
44
+ class BotoIntercept:
45
+ """Registers a before-call hook on a boto3 session's event system."""
46
+
47
+ def __init__(
48
+ self,
49
+ evaluator: PolicyEvaluator,
50
+ context: Optional[Dict[str, Any]] = None,
51
+ ) -> None:
52
+ self.evaluator = evaluator
53
+ self.context = context or {}
54
+ self._registered_session: Any = None
55
+
56
+ def _before_call(self, model: Any, params: Any, **kwargs: Any) -> None:
57
+ service_id = model.service_model.service_name
58
+ action = operation_to_action(service_id, model.name)
59
+ # Resource-level ARNs are not always derivable from params, so we
60
+ # authorize at action granularity with "*" resource by default.
61
+ # Callers wanting resource-level checks can pre-seed context.
62
+ self.evaluator.authorize(action, "*", self.context)
63
+
64
+ def attach(self, session: Any) -> None:
65
+ """Attach the hook to a boto3.Session (or boto3 module default)."""
66
+ events = session.events
67
+ events.register("before-call", self._before_call)
68
+ self._registered_session = session
69
+
70
+ def detach(self) -> None:
71
+ if self._registered_session is not None:
72
+ self._registered_session.events.unregister(
73
+ "before-call", self._before_call
74
+ )
75
+ self._registered_session = None
76
+
77
+
78
+ @contextmanager
79
+ def intercept_boto3(
80
+ evaluator: PolicyEvaluator,
81
+ session: Any = None,
82
+ context: Optional[Dict[str, Any]] = None,
83
+ ) -> Iterator[BotoIntercept]:
84
+ """Context manager that authorizes boto3 calls against ``evaluator``.
85
+
86
+ Usage::
87
+
88
+ with intercept_boto3(evaluator, session=my_session):
89
+ harness.invoke(event) # boto3 calls inside are checked
90
+ """
91
+ if session is None:
92
+ import boto3
93
+
94
+ session = boto3.DEFAULT_SESSION or boto3.Session()
95
+
96
+ hook = BotoIntercept(evaluator, context=context)
97
+ hook.attach(session)
98
+ try:
99
+ yield hook
100
+ finally:
101
+ hook.detach()
@@ -0,0 +1,90 @@
1
+ """IAM policy data model.
2
+
3
+ A ``PolicySet`` is the merged collection of statements that govern a
4
+ principal, plus optional metadata (permission boundary, source, timestamp)
5
+ used for cache freshness and honest scoping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ def _as_list(value: Any) -> List[Any]:
16
+ """IAM fields may be a scalar or a list; normalise to a list."""
17
+ if value is None:
18
+ return []
19
+ if isinstance(value, list):
20
+ return value
21
+ return [value]
22
+
23
+
24
+ @dataclass
25
+ class Statement:
26
+ """A single IAM policy statement."""
27
+
28
+ effect: str
29
+ actions: List[str]
30
+ resources: List[str]
31
+ conditions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
32
+ sid: Optional[str] = None
33
+
34
+ @classmethod
35
+ def from_dict(cls, raw: Dict[str, Any]) -> "Statement":
36
+ return cls(
37
+ effect=raw.get("Effect", "Deny"),
38
+ actions=[a.lower() for a in _as_list(raw.get("Action"))],
39
+ resources=_as_list(raw.get("Resource")) or ["*"],
40
+ conditions=raw.get("Condition", {}) or {},
41
+ sid=raw.get("Sid"),
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class PolicySet:
47
+ """Merged policies for a principal.
48
+
49
+ ``boundary`` holds the permission-boundary statements when known.
50
+ ``unscoped`` records permission surfaces deliberately not represented
51
+ (SCPs, resource-based policies) so consumers can report honest coverage.
52
+ """
53
+
54
+ statements: List[Statement] = field(default_factory=list)
55
+ boundary: List[Statement] = field(default_factory=list)
56
+ source: str = "unknown"
57
+ principal: str = "unknown"
58
+ fetched_at: float = field(default_factory=time.time)
59
+ unscoped: List[str] = field(
60
+ default_factory=lambda: ["scp", "resource_policy"]
61
+ )
62
+
63
+ @classmethod
64
+ def from_policy_documents(
65
+ cls,
66
+ documents: List[Dict[str, Any]],
67
+ boundary_documents: Optional[List[Dict[str, Any]]] = None,
68
+ source: str = "documents",
69
+ principal: str = "unknown",
70
+ ) -> "PolicySet":
71
+ """Build a PolicySet from raw policy-document dicts."""
72
+ statements: List[Statement] = []
73
+ for doc in documents:
74
+ for raw in _as_list(doc.get("Statement")):
75
+ statements.append(Statement.from_dict(raw))
76
+
77
+ boundary: List[Statement] = []
78
+ for doc in boundary_documents or []:
79
+ for raw in _as_list(doc.get("Statement")):
80
+ boundary.append(Statement.from_dict(raw))
81
+
82
+ return cls(
83
+ statements=statements,
84
+ boundary=boundary,
85
+ source=source,
86
+ principal=principal,
87
+ )
88
+
89
+ def age_seconds(self) -> float:
90
+ return time.time() - self.fetched_at
@@ -0,0 +1,190 @@
1
+ """Fetch a real IAM principal's policies and cache them.
2
+
3
+ Pulling the *actual* attached/inline/boundary policies of the execution role
4
+ means the evaluator tests against ground truth instead of a hand-written
5
+ guess that drifts. A Lambda runs as its **execution role**, so the puller is
6
+ role-oriented; a user ARN is accepted too for broader testing but resolved
7
+ and labelled accurately.
8
+
9
+ What can be pulled: identity policies (managed + inline) and the permission
10
+ boundary. What cannot: SCPs and resource-based policies — these are recorded
11
+ in ``PolicySet.unscoped`` rather than silently omitted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from .model import PolicySet
23
+
24
+ DEFAULT_CACHE_DIR = Path(
25
+ os.environ.get(
26
+ "LAMBDALOCAL_CACHE_DIR",
27
+ str(Path.home() / ".cache" / "lambda_preflight" / "iam"),
28
+ )
29
+ )
30
+
31
+
32
+ class PullError(Exception):
33
+ """Raised when policies cannot be fetched (e.g. missing IAM read access)."""
34
+
35
+
36
+ def pull_role(
37
+ role_name: str,
38
+ session: Any = None,
39
+ cache_dir: Optional[Path] = None,
40
+ max_age_seconds: Optional[float] = None,
41
+ ) -> PolicySet:
42
+ """Fetch (or load from cache) the merged policies for an execution role.
43
+
44
+ ``role_name`` may be a bare name or an ARN; the trailing name is used.
45
+ If a fresh cache entry exists (within ``max_age_seconds``, when given),
46
+ it is returned without an AWS call. Otherwise the live fan-out runs and
47
+ the result is cached.
48
+
49
+ Requires ``boto3`` and IAM read permissions for the live path. On an
50
+ access error, raises PullError with guidance to fall back to BYO-JSON.
51
+ """
52
+ name = role_name.split("/")[-1]
53
+ cache_dir = cache_dir or DEFAULT_CACHE_DIR
54
+
55
+ cached = _load_cache(name, cache_dir, max_age_seconds)
56
+ if cached is not None:
57
+ return cached
58
+
59
+ try:
60
+ import boto3 # local import: only needed on the live path
61
+ except ImportError as exc: # pragma: no cover
62
+ raise PullError(
63
+ "boto3 is required to pull policies from AWS. Install it, or use "
64
+ "PolicySet.from_policy_documents() with a local policy JSON."
65
+ ) from exc
66
+
67
+ session = session or boto3.Session()
68
+ iam = session.client("iam")
69
+
70
+ try:
71
+ documents = _fetch_role_policy_documents(iam, name)
72
+ boundary_docs = _fetch_boundary_documents(iam, name)
73
+ except Exception as exc: # noqa: BLE001 - normalise all AWS errors
74
+ raise PullError(
75
+ f"Could not read policies for role '{name}': {exc}. "
76
+ f"You may lack IAM read access; provide a policy JSON instead "
77
+ f"via PolicySet.from_policy_documents()."
78
+ ) from exc
79
+
80
+ policy_set = PolicySet.from_policy_documents(
81
+ documents=documents,
82
+ boundary_documents=boundary_docs,
83
+ source=f"aws:role/{name}",
84
+ principal=name,
85
+ )
86
+ _save_cache(name, policy_set, documents, boundary_docs, cache_dir)
87
+ return policy_set
88
+
89
+
90
+ def _fetch_role_policy_documents(iam: Any, name: str) -> List[Dict[str, Any]]:
91
+ """Fan out across managed + inline policies for a role."""
92
+ documents: List[Dict[str, Any]] = []
93
+
94
+ # Attached (managed) policies -> default version document.
95
+ attached = iam.list_attached_role_policies(RoleName=name)
96
+ for pol in attached.get("AttachedPolicies", []):
97
+ arn = pol["PolicyArn"]
98
+ meta = iam.get_policy(PolicyArn=arn)["Policy"]
99
+ version = iam.get_policy_version(
100
+ PolicyArn=arn, VersionId=meta["DefaultVersionId"]
101
+ )
102
+ documents.append(version["PolicyVersion"]["Document"])
103
+
104
+ # Inline policies -> document per policy name.
105
+ inline = iam.list_role_policies(RoleName=name)
106
+ for pol_name in inline.get("PolicyNames", []):
107
+ doc = iam.get_role_policy(RoleName=name, PolicyName=pol_name)
108
+ documents.append(doc["PolicyDocument"])
109
+
110
+ return documents
111
+
112
+
113
+ def _fetch_boundary_documents(iam: Any, name: str) -> List[Dict[str, Any]]:
114
+ """Fetch the permission-boundary document if one is attached."""
115
+ role = iam.get_role(RoleName=name)["Role"]
116
+ boundary = role.get("PermissionsBoundary")
117
+ if not boundary:
118
+ return []
119
+ arn = boundary["PermissionsBoundaryArn"]
120
+ meta = iam.get_policy(PolicyArn=arn)["Policy"]
121
+ version = iam.get_policy_version(
122
+ PolicyArn=arn, VersionId=meta["DefaultVersionId"]
123
+ )
124
+ return [version["PolicyVersion"]["Document"]]
125
+
126
+
127
+ def _cache_path(name: str, cache_dir: Path) -> Path:
128
+ return cache_dir / f"{name}.json"
129
+
130
+
131
+ def _load_cache(
132
+ name: str, cache_dir: Path, max_age_seconds: Optional[float]
133
+ ) -> Optional[PolicySet]:
134
+ path = _cache_path(name, cache_dir)
135
+ if not path.exists():
136
+ return None
137
+ try:
138
+ blob = json.loads(path.read_text())
139
+ except (json.JSONDecodeError, OSError):
140
+ return None
141
+
142
+ fetched_at = blob.get("fetched_at", 0)
143
+ if max_age_seconds is not None and (time.time() - fetched_at) > max_age_seconds:
144
+ return None
145
+
146
+ policy_set = PolicySet.from_policy_documents(
147
+ documents=blob.get("documents", []),
148
+ boundary_documents=blob.get("boundary_documents", []),
149
+ source=blob.get("source", f"cache:{name}"),
150
+ principal=name,
151
+ )
152
+ policy_set.fetched_at = fetched_at
153
+ return policy_set
154
+
155
+
156
+ def _save_cache(
157
+ name: str,
158
+ policy_set: PolicySet,
159
+ documents: List[Dict[str, Any]],
160
+ boundary_docs: List[Dict[str, Any]],
161
+ cache_dir: Path,
162
+ ) -> None:
163
+ cache_dir.mkdir(parents=True, exist_ok=True)
164
+ path = _cache_path(name, cache_dir)
165
+ payload = {
166
+ "principal": name,
167
+ "source": policy_set.source,
168
+ "fetched_at": policy_set.fetched_at,
169
+ "documents": documents,
170
+ "boundary_documents": boundary_docs,
171
+ }
172
+ path.write_text(json.dumps(payload, indent=2))
173
+
174
+
175
+ def load_role_policies(
176
+ role_name: str, cache_dir: Optional[Path] = None
177
+ ) -> PolicySet:
178
+ """Load a role's policies from cache only, without any AWS call.
179
+
180
+ Convenience for offline mode: reuse the last pull with no charges and no
181
+ credentials. Raises PullError if nothing is cached.
182
+ """
183
+ name = role_name.split("/")[-1]
184
+ cached = _load_cache(name, cache_dir or DEFAULT_CACHE_DIR, None)
185
+ if cached is None:
186
+ raise PullError(
187
+ f"No cached policies for '{name}'. Run pull_role() once while "
188
+ f"online, or supply a policy JSON."
189
+ )
190
+ return cached
@@ -0,0 +1,13 @@
1
+ """In-process Lambda runtime shim."""
2
+
3
+ from .context import LambdaContext
4
+ from .errors import HandlerLoadError, LambdaTimeout
5
+ from .harness import InvocationResult, LambdaHarness
6
+
7
+ __all__ = [
8
+ "LambdaHarness",
9
+ "InvocationResult",
10
+ "LambdaContext",
11
+ "LambdaTimeout",
12
+ "HandlerLoadError",
13
+ ]
@@ -0,0 +1,75 @@
1
+ """Faithful reproduction of the AWS Lambda ``context`` object.
2
+
3
+ The context object AWS passes to a handler exposes a specific set of
4
+ attributes and one time-sensitive method, ``get_remaining_time_in_millis``.
5
+ Reproducing these accurately is what lets handler code that reads the
6
+ context behave locally the way it does in AWS.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+
17
+ @dataclass
18
+ class ClientContext:
19
+ """Mobile SDK client context. Present in the real object; usually None."""
20
+
21
+ client: Optional[dict] = None
22
+ custom: Optional[dict] = None
23
+ env: Optional[dict] = None
24
+
25
+
26
+ @dataclass
27
+ class CognitoIdentity:
28
+ """Cognito identity context. Present in the real object; usually None."""
29
+
30
+ cognito_identity_id: Optional[str] = None
31
+ cognito_identity_pool_id: Optional[str] = None
32
+
33
+
34
+ class LambdaContext:
35
+ """In-process stand-in for the AWS Lambda runtime context.
36
+
37
+ The remaining-time clock is backed by a real monotonic deadline so that
38
+ handlers which budget work against ``get_remaining_time_in_millis()``
39
+ observe the same countdown behaviour they would in AWS.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ function_name: str = "local-function",
45
+ function_version: str = "$LATEST",
46
+ memory_limit_in_mb: int = 128,
47
+ timeout_seconds: float = 3.0,
48
+ region: str = "us-east-1",
49
+ account_id: str = "000000000000",
50
+ aws_request_id: Optional[str] = None,
51
+ log_group_name: Optional[str] = None,
52
+ log_stream_name: Optional[str] = None,
53
+ ) -> None:
54
+ self.function_name = function_name
55
+ self.function_version = function_version
56
+ self.memory_limit_in_mb = str(memory_limit_in_mb)
57
+ self.aws_request_id = aws_request_id or str(uuid.uuid4())
58
+ self.log_group_name = log_group_name or f"/aws/lambda/{function_name}"
59
+ self.log_stream_name = (
60
+ log_stream_name
61
+ or f"{time.strftime('%Y/%m/%d')}/[{function_version}]{uuid.uuid4().hex}"
62
+ )
63
+ self.invoked_function_arn = (
64
+ f"arn:aws:lambda:{region}:{account_id}:function:{function_name}"
65
+ )
66
+ self.identity = CognitoIdentity()
67
+ self.client_context = ClientContext()
68
+
69
+ # Real monotonic deadline drives the remaining-time countdown.
70
+ self._deadline = time.monotonic() + timeout_seconds
71
+
72
+ def get_remaining_time_in_millis(self) -> int:
73
+ """Milliseconds left before the configured timeout, floored at 0."""
74
+ remaining = (self._deadline - time.monotonic()) * 1000.0
75
+ return max(0, int(remaining))
@@ -0,0 +1,34 @@
1
+ """AWS-shape error serialization.
2
+
3
+ When a handler raises an unhandled exception, the Lambda runtime returns a
4
+ specific JSON envelope. Downstream consumers (Step Functions, Lambda
5
+ Destinations, the invoke API) parse that exact shape, so reproducing it is
6
+ part of behaving like AWS rather than merely running the function.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import traceback
12
+ from typing import Any, Dict
13
+
14
+
15
+ class LambdaTimeout(Exception):
16
+ """Raised when a handler exceeds its configured timeout."""
17
+
18
+
19
+ class HandlerLoadError(Exception):
20
+ """Raised when the ``module.function`` handler string cannot be resolved."""
21
+
22
+
23
+ def format_exception(exc: BaseException) -> Dict[str, Any]:
24
+ """Return the AWS-style error envelope for an unhandled exception.
25
+
26
+ Matches the runtime's structure: ``errorType``, ``errorMessage`` and a
27
+ ``stackTrace`` list of formatted frames.
28
+ """
29
+ stack = traceback.format_tb(exc.__traceback__)
30
+ return {
31
+ "errorType": type(exc).__name__,
32
+ "errorMessage": str(exc),
33
+ "stackTrace": [frame.rstrip("\n") for frame in stack],
34
+ }