ahi-aws 0.4.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.
ahi/aws/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Thin, typed boto3 helpers for AHI."""
2
+
3
+ from ahi.aws.athena import AthenaClient
4
+ from ahi.aws.auth import AwsIdentity, aws_session, check_identity
5
+ from ahi.aws.errors import (
6
+ AuthError,
7
+ AwsUnreachableError,
8
+ CredentialsNotFoundError,
9
+ CredentialsRejectedError,
10
+ DownloadFailedError,
11
+ ProfileNotFoundError,
12
+ QueryError,
13
+ QueryFailedError,
14
+ RegionNotConfiguredError,
15
+ S3Error,
16
+ SsoLoginRequiredError,
17
+ UploadFailedError,
18
+ )
19
+ from ahi.aws.redshift import RedshiftDataClient
20
+ from ahi.aws.results import QueryResult
21
+ from ahi.aws.s3 import S3Object, S3Storage
22
+
23
+ __all__ = [
24
+ "AthenaClient",
25
+ "AuthError",
26
+ "AwsIdentity",
27
+ "AwsUnreachableError",
28
+ "CredentialsNotFoundError",
29
+ "CredentialsRejectedError",
30
+ "DownloadFailedError",
31
+ "ProfileNotFoundError",
32
+ "QueryError",
33
+ "QueryFailedError",
34
+ "QueryResult",
35
+ "RedshiftDataClient",
36
+ "RegionNotConfiguredError",
37
+ "S3Error",
38
+ "S3Object",
39
+ "S3Storage",
40
+ "SsoLoginRequiredError",
41
+ "UploadFailedError",
42
+ "aws_session",
43
+ "check_identity",
44
+ ]
ahi/aws/athena.py ADDED
@@ -0,0 +1,167 @@
1
+ """Thin wrapper over Athena for running SQL.
2
+
3
+ Like Redshift's Data API, Athena is asynchronous: start an execution, poll its
4
+ state, then page the results. ``AthenaClient.run_query`` collapses that into one
5
+ synchronous call returning a :class:`~ahi.aws.results.QueryResult`.
6
+
7
+ from ahi.aws.athena import AthenaClient
8
+
9
+ client = AthenaClient(workgroup="primary", database="analytics")
10
+ result = client.run_query(
11
+ "select name from users where id = ?",
12
+ parameters=[42],
13
+ )
14
+ for row in result:
15
+ print(row["name"])
16
+
17
+ Athena returns every value as a string (or NULL); this wrapper preserves that
18
+ rather than guessing types.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from ahi.aws.errors import QueryFailedError
26
+ from ahi.aws.results import QueryResult
27
+ from ahi.core import get_logger, poll_until
28
+
29
+ if TYPE_CHECKING: # pragma: no cover - typing only
30
+ from collections.abc import Sequence
31
+
32
+ _log = get_logger("aws.athena")
33
+
34
+ # Terminal states reported by get_query_execution.
35
+ _TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
36
+
37
+
38
+ class AthenaClient:
39
+ """Run queries against Athena.
40
+
41
+ Args:
42
+ workgroup: Athena workgroup. Result location and encryption are usually
43
+ configured on the workgroup, so ``output_location`` is optional.
44
+ database: Default Glue/Athena database (overridable per call).
45
+ catalog: Default data catalog (for example ``"AwsDataCatalog"``).
46
+ output_location: ``s3://`` prefix for results. Only needed when the
47
+ workgroup does not enforce one.
48
+ region_name: AWS region; defaults to the ambient boto3 configuration.
49
+ client: A pre-built ``athena`` boto3 client (injection point for tests).
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ workgroup: str = "primary",
56
+ database: str | None = None,
57
+ catalog: str | None = None,
58
+ output_location: str | None = None,
59
+ region_name: str | None = None,
60
+ client: Any | None = None,
61
+ ) -> None:
62
+ if client is None:
63
+ import boto3
64
+
65
+ client = boto3.client("athena", region_name=region_name)
66
+ self._client = client
67
+ self._workgroup = workgroup
68
+ self._database = database
69
+ self._catalog = catalog
70
+ self._output_location = output_location
71
+
72
+ def run_query(
73
+ self,
74
+ sql: str,
75
+ *,
76
+ parameters: Sequence[Any] | None = None,
77
+ database: str | None = None,
78
+ timeout: float = 300.0,
79
+ poll_interval: float = 1.0,
80
+ ) -> QueryResult:
81
+ """Execute ``sql`` and return its result.
82
+
83
+ Args:
84
+ sql: The SQL to run. Use positional ``?`` placeholders with
85
+ ``parameters`` rather than formatting values into the string.
86
+ parameters: Positional bind parameters, in order of ``?`` appearance.
87
+ database: Override the default database for this query.
88
+ timeout: Maximum seconds to wait for completion.
89
+ poll_interval: Initial delay between status polls.
90
+
91
+ Raises:
92
+ QueryFailedError: If the execution ends in ``FAILED``/``CANCELLED``.
93
+ PollTimeoutError: If it does not finish within ``timeout``.
94
+ """
95
+ query_id = self._start(sql, parameters, database)
96
+ _log.debug("started athena execution %s", query_id)
97
+
98
+ execution = poll_until(
99
+ lambda: self._client.get_query_execution(QueryExecutionId=query_id),
100
+ lambda e: e["QueryExecution"]["Status"]["State"] in _TERMINAL_STATES,
101
+ timeout=timeout,
102
+ interval=poll_interval,
103
+ )
104
+
105
+ status = execution["QueryExecution"]["Status"]
106
+ state = status["State"]
107
+ if state != "SUCCEEDED":
108
+ raise QueryFailedError(query_id, state, status.get("StateChangeReason"))
109
+ return self._collect(query_id)
110
+
111
+ def _start(
112
+ self,
113
+ sql: str,
114
+ parameters: Sequence[Any] | None,
115
+ database: str | None,
116
+ ) -> str:
117
+ args: dict[str, Any] = {"QueryString": sql, "WorkGroup": self._workgroup}
118
+
119
+ context: dict[str, str] = {}
120
+ db = database or self._database
121
+ if db is not None:
122
+ context["Database"] = db
123
+ if self._catalog is not None:
124
+ context["Catalog"] = self._catalog
125
+ if context:
126
+ args["QueryExecutionContext"] = context
127
+
128
+ if self._output_location is not None:
129
+ args["ResultConfiguration"] = {"OutputLocation": self._output_location}
130
+ if parameters:
131
+ args["ExecutionParameters"] = [str(value) for value in parameters]
132
+
133
+ return self._client.start_query_execution(**args)["QueryExecutionId"]
134
+
135
+ def _collect(self, query_id: str) -> QueryResult:
136
+ columns: list[str] = []
137
+ rows: list[dict[str, Any]] = []
138
+ next_token: str | None = None
139
+ first_page = True
140
+
141
+ while True:
142
+ kwargs: dict[str, Any] = {"QueryExecutionId": query_id}
143
+ if next_token:
144
+ kwargs["NextToken"] = next_token
145
+ page = self._client.get_query_results(**kwargs)
146
+ result_set = page.get("ResultSet", {})
147
+
148
+ if not columns:
149
+ column_info = result_set.get("ResultSetMetadata", {}).get("ColumnInfo", [])
150
+ columns = [col["Label"] for col in column_info]
151
+
152
+ page_rows = result_set.get("Rows", [])
153
+ # Athena repeats the column labels as the first data row of the first
154
+ # page for SELECT statements; skip it so it isn't returned as data.
155
+ if first_page and page_rows:
156
+ page_rows = page_rows[1:]
157
+ first_page = False
158
+
159
+ for row in page_rows:
160
+ values = [cell.get("VarCharValue") for cell in row.get("Data", [])]
161
+ rows.append(dict(zip(columns, values, strict=False)))
162
+
163
+ next_token = page.get("NextToken")
164
+ if not next_token:
165
+ break
166
+
167
+ return QueryResult(columns=columns, rows=rows, query_id=query_id)
ahi/aws/auth.py ADDED
@@ -0,0 +1,227 @@
1
+ """AWS credentials for AHI apps
2
+
3
+ This module provides :func:`aws_session`, which builds a boto3 session using
4
+ boto3's standard credential resolution, :func:`check_identity`, which checks it
5
+ with a single ``sts:GetCallerIdentity`` call, and :class:`AwsIdentity`, which
6
+ records who the credentials belong to and how they were found, great for provenance.
7
+
8
+ Caters to common ways of running AHI apps:
9
+
10
+ - **Local SSO** (a person at their machine): their own SSO login, done
11
+ beforehand with ``aws sso login``, picked up from ``AWS_PROFILE`` or the
12
+ default profile.
13
+ - **Attached role** (EC2 instance, ECS/Fargate task, scheduled jobs): the role
14
+ attached to the resource, found by boto3 with no profile and no login.
15
+
16
+ Hand ``session.client(...)`` to the other helpers:
17
+
18
+ from ahi.aws.athena import AthenaClient
19
+ from ahi.aws.auth import aws_session, check_identity
20
+
21
+ session = aws_session()
22
+ identity = check_identity(session)
23
+ log.info("AWS: %s via %s", identity.arn, identity.method)
24
+
25
+ athena = AthenaClient(workgroup="primary", client=session.client("athena"))
26
+
27
+ This module does not log in for you: no browser, no AWS CLI. When credentials are
28
+ missing or expired it raises an :class:`~ahi.aws.errors.AuthError` subclass
29
+ saying what to run (for example ``aws sso login``).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from dataclasses import dataclass
36
+ from typing import Any
37
+
38
+ import boto3
39
+ from botocore.exceptions import (
40
+ BotoCoreError,
41
+ ClientError,
42
+ HTTPClientError,
43
+ NoCredentialsError,
44
+ NoRegionError,
45
+ PartialCredentialsError,
46
+ ProfileNotFound,
47
+ SSOTokenLoadError,
48
+ TokenRetrievalError,
49
+ UnauthorizedSSOTokenError,
50
+ )
51
+ from botocore.exceptions import ConnectionError as BotoConnectionError
52
+
53
+ from ahi.aws.errors import (
54
+ AuthError,
55
+ AwsUnreachableError,
56
+ CredentialsNotFoundError,
57
+ CredentialsRejectedError,
58
+ ProfileNotFoundError,
59
+ RegionNotConfiguredError,
60
+ SsoLoginRequiredError,
61
+ )
62
+ from ahi.core import get_logger
63
+
64
+ _log = get_logger("aws.auth")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AwsIdentity:
69
+ """The identity behind a set of AWS credentials
70
+
71
+ Returned by :func:`check_identity`. Log it, or record it with a run's provenance.
72
+
73
+ Attributes:
74
+ account: The AWS account id.
75
+ arn: The caller's ARN (an assumed-role ARN for SSO and attached roles).
76
+ user_id: The caller's unique id.
77
+ region: The region the identity check ran in.
78
+ profile: The named profile in effect, or ``None`` for the default
79
+ profile or when no profile is in use.
80
+ method: How botocore found the credentials, from
81
+ ``session.get_credentials().method``: for example ``"sso"`` for an
82
+ SSO login, ``"iam-role"`` for an EC2 instance role, or
83
+ ``"container-role"`` for an ECS/Fargate task role. ``None`` if it
84
+ could not tell.
85
+ """
86
+
87
+ account: str
88
+ arn: str
89
+ user_id: str
90
+ region: str | None
91
+ profile: str | None
92
+ method: str | None
93
+
94
+
95
+ def aws_session(*, profile: str | None = None, region: str | None = None) -> boto3.Session:
96
+ """Return a boto3 session using the standard credential resolution.
97
+
98
+ Credentials lookup order is the same as boto3's, but;
99
+ later sources are not checked:
100
+
101
+ 1. Environment variables: ``AWS_ACCESS_KEY_ID``, ``AWS_SECRET_ACCESS_KEY``
102
+ 2. The profile in use: ``AWS_PROFILE``, else ``[default]`` in ``~/.aws``.
103
+ 3. The attached role: ECS/Fargate task role, else EC2 instance role.
104
+
105
+ Passing ``profile`` skips this list and uses only that profile and authenticate via SSO.
106
+
107
+ Region lookup order;
108
+ 1. ``region`` argument to this function.
109
+ 2. Environment variables: ``AWS_REGION``, ``AWS_DEFAULT_REGION``.
110
+ 3. The profile's ``region`` setting.
111
+
112
+ Makes no network calls; use :func:`check_identity` to verify the session.
113
+
114
+ Args:
115
+ profile: AWS SSO profile name to use. This uses SSO method over other credential
116
+ sources (environment keys, ``AWS_PROFILE``, attached roles).
117
+ If you pass one, it must exist on the machine running the code, or this raises
118
+ ``ProfileNotFoundError``. On machines that use an attached role
119
+ (EC2, ECS), pass ``None``.
120
+ region: Region override.
121
+
122
+ Returns:
123
+ The session, ready for ``session.client(...)``.
124
+
125
+ Raises:
126
+ ProfileNotFoundError: If the profile is not configured.
127
+ RegionNotConfiguredError: If no region resolves.
128
+ """
129
+ region_name = region or os.environ.get("AWS_REGION") or None
130
+ try:
131
+ session = boto3.Session(profile_name=profile, region_name=region_name)
132
+ resolved_region = session.region_name
133
+ except ProfileNotFound as exc:
134
+ raise _to_auth_error(exc, profile=profile, method=None, region=None) from exc
135
+
136
+ profile_in_use = _profile_in_use(session)
137
+ if not resolved_region:
138
+ raise RegionNotConfiguredError(profile=profile_in_use)
139
+ _log.debug("AWS session: profile=%s region=%s", profile_in_use or "<default>", resolved_region)
140
+ return session
141
+
142
+
143
+ def check_identity(session: boto3.Session, *, client: Any | None = None) -> AwsIdentity:
144
+ """Return the identity behind the session's credentials, checked via an STS call.
145
+
146
+ Call this once at the start of a run so missing or expired credentials
147
+ fail early with a message that says what to do.
148
+
149
+ Args:
150
+ session: The session to check, usually from :func:`aws_session`.
151
+ client: A pre-built ``sts`` boto3 client (injection point for tests).
152
+ Defaults to ``session.client("sts")``.
153
+
154
+ Returns:
155
+ The caller's identity and how its credentials were found.
156
+
157
+ Raises:
158
+ SsoLoginRequiredError: If the SSO login is missing or expired.
159
+ CredentialsNotFoundError: If no credentials are found.
160
+ CredentialsRejectedError: If AWS rejects the credentials.
161
+ AwsUnreachableError: If AWS STS cannot be reached.
162
+ AuthError: For any other credential failure (the base of all the above).
163
+ """
164
+ profile = _profile_in_use(session)
165
+ method: str | None = None
166
+ region: str | None = session.region_name
167
+ try:
168
+ credentials = session.get_credentials()
169
+ method = credentials.method if credentials is not None else None
170
+ sts = client if client is not None else session.client("sts")
171
+ region = sts.meta.region_name
172
+ response = sts.get_caller_identity()
173
+ except (BotoCoreError, ClientError) as exc:
174
+ raise _to_auth_error(exc, profile=profile, method=method, region=region) from exc
175
+
176
+ identity = AwsIdentity(
177
+ account=response["Account"],
178
+ arn=response["Arn"],
179
+ user_id=response["UserId"],
180
+ region=region,
181
+ profile=profile,
182
+ method=method,
183
+ )
184
+ _log.debug("AWS identity: %s via %s", identity.arn, identity.method)
185
+ return identity
186
+
187
+
188
+ def _profile_in_use(session: boto3.Session) -> str | None:
189
+ """Return the profile in use, or ``None`` when it's the default profile."""
190
+ name = session.profile_name
191
+ return None if name == "default" else name
192
+
193
+
194
+ def _to_auth_error(
195
+ exc: BotoCoreError | ClientError,
196
+ *,
197
+ profile: str | None,
198
+ method: str | None,
199
+ region: str | None,
200
+ ) -> AuthError:
201
+ """Return the AHI auth error that matches a botocore failure.
202
+
203
+ Maps each botocore credential, region and network error to its ``AuthError``
204
+ subclass, adding context botocore doesn't have (the profile in effect, how
205
+ the credentials were found, the region) so the message can say what to do
206
+ next. Anything not recognised becomes a plain ``AuthError``.
207
+ """
208
+ reason = str(exc)
209
+ if isinstance(exc, ProfileNotFound):
210
+ return ProfileNotFoundError(profile=profile or exc.kwargs.get("profile"), reason=reason)
211
+ if isinstance(exc, SSOTokenLoadError | UnauthorizedSSOTokenError | TokenRetrievalError):
212
+ return SsoLoginRequiredError(profile=profile, reason=reason)
213
+ if isinstance(exc, NoCredentialsError | PartialCredentialsError):
214
+ return CredentialsNotFoundError(profile=profile, reason=reason)
215
+ if isinstance(exc, NoRegionError):
216
+ return RegionNotConfiguredError(profile=profile, reason=reason)
217
+ if isinstance(exc, BotoConnectionError | HTTPClientError):
218
+ return AwsUnreachableError(reason=reason, region=region, profile=profile)
219
+ if isinstance(exc, ClientError):
220
+ error = exc.response.get("Error", {})
221
+ return CredentialsRejectedError(
222
+ error.get("Code", "Unknown"),
223
+ reason=reason, # botocore's full message, which names the operation too
224
+ method=method,
225
+ profile=profile,
226
+ )
227
+ return AuthError(profile=profile, reason=reason)
ahi/aws/errors.py ADDED
@@ -0,0 +1,202 @@
1
+ """Errors raised by the AWS helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from ahi.core.errors import AhiError
8
+
9
+
10
+ class QueryError(AhiError):
11
+ """Base class for query-execution failures."""
12
+
13
+
14
+ class QueryFailedError(QueryError):
15
+ """Raised when a query reaches a non-successful terminal state.
16
+
17
+ Attributes:
18
+ query_id: The service-assigned statement/execution id.
19
+ state: The terminal state reported by the service (for example
20
+ ``"FAILED"`` or ``"ABORTED"``).
21
+ reason: The service-provided failure reason, if any.
22
+ """
23
+
24
+ def __init__(self, query_id: str, state: str, reason: str | None = None) -> None:
25
+ self.query_id = query_id
26
+ self.state = state
27
+ self.reason = reason
28
+ detail = f": {reason}" if reason else ""
29
+ super().__init__(f"Query {query_id} ended in state {state}{detail}")
30
+
31
+
32
+ class S3Error(AhiError):
33
+ """Base class for S3 failures.
34
+
35
+ Attributes:
36
+ bucket: The bucket.
37
+ key: The object key, or the prefix being listed.
38
+ reason: The boto3/botocore message.
39
+ code: The S3 error code (for example ``"AccessDenied"``, or ``"404"`` for a
40
+ missing key), or ``None``.
41
+ """
42
+
43
+ _action: ClassVar[str] = "access"
44
+
45
+ def __init__(self, bucket: str, key: str, reason: str, *, code: str | None = None) -> None:
46
+ self.bucket = bucket
47
+ self.key = key
48
+ self.reason = reason
49
+ self.code = code
50
+ super().__init__(f"Failed to {self._action} s3://{bucket}/{key}: {reason}")
51
+
52
+
53
+ class UploadFailedError(S3Error):
54
+ """Raised when an upload to S3 fails."""
55
+
56
+ _action = "upload"
57
+
58
+
59
+ class DownloadFailedError(S3Error):
60
+ """Raised when listing or downloading from S3 fails."""
61
+
62
+ _action = "download"
63
+
64
+
65
+ class AuthError(AhiError):
66
+ """Base class for AWS credential and region failures.
67
+
68
+ Raised as-is when botocore fails in a way no subclass covers. The message
69
+ says what went wrong, what botocore reported, and what to do about it, so
70
+ it can be shown to the person running the app as-is without hiding
71
+ botocore's own detail. The botocore error is also kept as ``__cause__``.
72
+
73
+ Attributes:
74
+ profile: The named AWS profile in effect, or ``None`` for the default
75
+ profile or when no profile is in use.
76
+ reason: The botocore message, or ``None`` when no botocore error is
77
+ behind the failure.
78
+ hint: What to do next (for example the ``aws sso login`` command to run).
79
+ """
80
+
81
+ def __init__(self, *, profile: str | None = None, reason: str | None = None) -> None:
82
+ self.profile = profile
83
+ self.reason = reason
84
+ self.hint = self._hint()
85
+ detail = f" (botocore: {reason.rstrip('.')})" if reason else ""
86
+ super().__init__(f"{self._problem()}{detail}. {self.hint}")
87
+
88
+ def _problem(self) -> str:
89
+ return "AWS credentials could not be resolved"
90
+
91
+ def _hint(self) -> str:
92
+ return "Check the AWS configuration with `aws configure list`."
93
+
94
+ def _login_command(self) -> str:
95
+ if self.profile:
96
+ return f"aws sso login --profile {self.profile}"
97
+ return "aws sso login"
98
+
99
+
100
+ class ProfileNotFoundError(AuthError):
101
+ """Raised when ``AWS_PROFILE`` or ``profile=`` names a profile that is not configured."""
102
+
103
+ def _problem(self) -> str:
104
+ return f"AWS profile '{self.profile}' is not configured"
105
+
106
+ def _hint(self) -> str:
107
+ return (
108
+ "Check AWS_PROFILE (leave it unset to use a role attached to this machine), "
109
+ "or run `aws configure sso` to set the profile up."
110
+ )
111
+
112
+
113
+ class SsoLoginRequiredError(AuthError):
114
+ """Raised when the AWS SSO login is missing or has expired."""
115
+
116
+ def _problem(self) -> str:
117
+ return "AWS SSO login is missing or expired"
118
+
119
+ def _hint(self) -> str:
120
+ return f"Run `{self._login_command()}`, then retry."
121
+
122
+
123
+ class CredentialsNotFoundError(AuthError):
124
+ """Raised when no AWS credentials are found: no login, no keys, no attached role."""
125
+
126
+ def _problem(self) -> str:
127
+ return "No AWS credentials found"
128
+
129
+ def _hint(self) -> str:
130
+ return f"Log in with `{self._login_command()}`, or run where an AWS role is attached."
131
+
132
+
133
+ class CredentialsRejectedError(AuthError):
134
+ """Raised when AWS rejects the credentials, for example because they expired.
135
+
136
+ Attributes:
137
+ code: The AWS error code (for example ``"ExpiredToken"`` or
138
+ ``"AccessDenied"``).
139
+ method: How botocore found the credentials (for example ``"sso"``), or
140
+ ``None``.
141
+ expired: Whether the code means the credentials have lapsed.
142
+ """
143
+
144
+ # AWS error codes that mean the credentials were valid once but have lapsed.
145
+ _expired_codes: ClassVar[frozenset[str]] = frozenset(
146
+ {"ExpiredToken", "ExpiredTokenException", "RequestExpired"}
147
+ )
148
+
149
+ def __init__(
150
+ self,
151
+ code: str,
152
+ *,
153
+ reason: str,
154
+ method: str | None = None,
155
+ profile: str | None = None,
156
+ ) -> None:
157
+ self.code = code
158
+ self.method = method
159
+ self.expired = code in self._expired_codes
160
+ super().__init__(profile=profile, reason=reason)
161
+
162
+ def _problem(self) -> str:
163
+ return f"AWS rejected the credentials ({self.code})"
164
+
165
+ def _hint(self) -> str:
166
+ source = self.method or "an unknown source"
167
+ if self.expired:
168
+ return (
169
+ f"The credentials from {source} have expired; "
170
+ f"run `{self._login_command()}`, then retry."
171
+ )
172
+ return f"Check the credentials from {source} are valid and allowed to call AWS."
173
+
174
+
175
+ class RegionNotConfiguredError(AuthError):
176
+ """Raised when no AWS region resolves from the arguments, environment or profile."""
177
+
178
+ def _problem(self) -> str:
179
+ return "No AWS region configured"
180
+
181
+ def _hint(self) -> str:
182
+ return "Set AWS_REGION, add `region` to the AWS profile, or pass region=."
183
+
184
+
185
+ class AwsUnreachableError(AuthError):
186
+ """Raised when AWS STS cannot be reached (network, proxy or SSL failure).
187
+
188
+ Attributes:
189
+ region: The region the call was made in, or ``None``.
190
+ """
191
+
192
+ def __init__(
193
+ self, *, reason: str, region: str | None = None, profile: str | None = None
194
+ ) -> None:
195
+ self.region = region
196
+ super().__init__(profile=profile, reason=reason)
197
+
198
+ def _problem(self) -> str:
199
+ return f"Cannot reach AWS STS in {self.region or 'the configured region'}"
200
+
201
+ def _hint(self) -> str:
202
+ return "Check network access to AWS (proxy, firewall, VPC endpoint)."
ahi/aws/redshift.py ADDED
@@ -0,0 +1,175 @@
1
+ """Thin wrapper over the Redshift Data API for running SQL.
2
+
3
+ The Redshift Data API is asynchronous: you submit a statement, poll its status,
4
+ then page through the result set. ``RedshiftDataClient.run_query`` collapses that
5
+ into one synchronous call returning a :class:`~ahi.aws.results.QueryResult`.
6
+
7
+ It targets Redshift Serverless (``workgroup_name``) or a provisioned cluster
8
+ (``cluster_identifier``). Authentication to the database is via the workgroup's
9
+ default IAM auth, ``db_user`` (provisioned temp credentials), or ``secret_arn``
10
+ (Secrets Manager) — whichever your setup uses.
11
+
12
+ from ahi.aws.redshift import RedshiftDataClient
13
+
14
+ client = RedshiftDataClient(workgroup_name="analytics", database="dev")
15
+ result = client.run_query(
16
+ "select count(*) from events where day = :day",
17
+ parameters={"day": "2026-06-26"},
18
+ )
19
+ print(result.scalar())
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from ahi.aws.errors import QueryFailedError
27
+ from ahi.aws.results import QueryResult
28
+ from ahi.core import get_logger, poll_until
29
+
30
+ if TYPE_CHECKING: # pragma: no cover - typing only
31
+ from collections.abc import Mapping
32
+
33
+ _log = get_logger("aws.redshift")
34
+
35
+ # Terminal states reported by describe_statement.
36
+ _TERMINAL_STATES = frozenset({"FINISHED", "FAILED", "ABORTED"})
37
+
38
+
39
+ def _decode_field(field: Mapping[str, Any]) -> Any:
40
+ """Decode one Redshift Data API field into a native Python value."""
41
+ if field.get("isNull"):
42
+ return None
43
+ for key in ("stringValue", "longValue", "doubleValue", "booleanValue", "blobValue"):
44
+ if key in field:
45
+ return field[key]
46
+ return None
47
+
48
+
49
+ class RedshiftDataClient:
50
+ """Run queries against Redshift via the Data API.
51
+
52
+ Args:
53
+ workgroup_name: Redshift Serverless workgroup (serverless target).
54
+ cluster_identifier: Provisioned cluster id (provisioned target).
55
+ database: Default database for queries (overridable per call).
56
+ db_user: Database user for provisioned temporary credentials.
57
+ secret_arn: Secrets Manager ARN holding database credentials.
58
+ region_name: AWS region; defaults to the ambient boto3 configuration.
59
+ client: A pre-built ``redshift-data`` boto3 client. When provided, the
60
+ connection arguments above are still used per request; mainly an
61
+ injection point for tests.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ workgroup_name: str | None = None,
68
+ cluster_identifier: str | None = None,
69
+ database: str | None = None,
70
+ db_user: str | None = None,
71
+ secret_arn: str | None = None,
72
+ region_name: str | None = None,
73
+ client: Any | None = None,
74
+ ) -> None:
75
+ if workgroup_name is None and cluster_identifier is None:
76
+ raise ValueError("provide either workgroup_name or cluster_identifier")
77
+ if client is None:
78
+ import boto3
79
+
80
+ client = boto3.client("redshift-data", region_name=region_name)
81
+ self._client = client
82
+ self._workgroup_name = workgroup_name
83
+ self._cluster_identifier = cluster_identifier
84
+ self._database = database
85
+ self._db_user = db_user
86
+ self._secret_arn = secret_arn
87
+
88
+ def run_query(
89
+ self,
90
+ sql: str,
91
+ *,
92
+ parameters: Mapping[str, Any] | None = None,
93
+ database: str | None = None,
94
+ timeout: float = 300.0,
95
+ poll_interval: float = 1.0,
96
+ ) -> QueryResult:
97
+ """Execute ``sql`` and return its result.
98
+
99
+ Args:
100
+ sql: The SQL to run. Use named placeholders (``:name``) with
101
+ ``parameters`` rather than formatting values into the string.
102
+ parameters: Named bind parameters. Values are sent as strings, per
103
+ the Data API contract.
104
+ database: Override the default database for this query.
105
+ timeout: Maximum seconds to wait for completion.
106
+ poll_interval: Initial delay between status polls.
107
+
108
+ Raises:
109
+ QueryFailedError: If the statement ends in ``FAILED``/``ABORTED``.
110
+ PollTimeoutError: If it does not finish within ``timeout``.
111
+ """
112
+ statement_id = self._submit(sql, parameters, database)
113
+ _log.debug("submitted redshift statement %s", statement_id)
114
+
115
+ description = poll_until(
116
+ lambda: self._client.describe_statement(Id=statement_id),
117
+ lambda d: d["Status"] in _TERMINAL_STATES,
118
+ timeout=timeout,
119
+ interval=poll_interval,
120
+ )
121
+
122
+ status = description["Status"]
123
+ if status != "FINISHED":
124
+ raise QueryFailedError(statement_id, status, description.get("Error"))
125
+
126
+ if not description.get("HasResultSet"):
127
+ return QueryResult(query_id=statement_id)
128
+ return self._collect(statement_id)
129
+
130
+ def _submit(
131
+ self,
132
+ sql: str,
133
+ parameters: Mapping[str, Any] | None,
134
+ database: str | None,
135
+ ) -> str:
136
+ args: dict[str, Any] = {"Sql": sql}
137
+ if self._workgroup_name is not None:
138
+ args["WorkgroupName"] = self._workgroup_name
139
+ if self._cluster_identifier is not None:
140
+ args["ClusterIdentifier"] = self._cluster_identifier
141
+ db = database or self._database
142
+ if db is not None:
143
+ args["Database"] = db
144
+ if self._db_user is not None:
145
+ args["DbUser"] = self._db_user
146
+ if self._secret_arn is not None:
147
+ args["SecretArn"] = self._secret_arn
148
+ if parameters:
149
+ args["Parameters"] = [
150
+ {"name": name, "value": str(value)} for name, value in parameters.items()
151
+ ]
152
+ return self._client.execute_statement(**args)["Id"]
153
+
154
+ def _collect(self, statement_id: str) -> QueryResult:
155
+ columns: list[str] = []
156
+ rows: list[dict[str, Any]] = []
157
+ next_token: str | None = None
158
+
159
+ while True:
160
+ kwargs: dict[str, Any] = {"Id": statement_id}
161
+ if next_token:
162
+ kwargs["NextToken"] = next_token
163
+ page = self._client.get_statement_result(**kwargs)
164
+
165
+ if not columns:
166
+ columns = [col["name"] for col in page.get("ColumnMetadata", [])]
167
+ for record in page.get("Records", []):
168
+ values = [_decode_field(field) for field in record]
169
+ rows.append(dict(zip(columns, values, strict=False)))
170
+
171
+ next_token = page.get("NextToken")
172
+ if not next_token:
173
+ break
174
+
175
+ return QueryResult(columns=columns, rows=rows, query_id=statement_id)
ahi/aws/results.py ADDED
@@ -0,0 +1,47 @@
1
+ """The result type returned by the query helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class QueryResult:
12
+ """The outcome of a successful query.
13
+
14
+ Iterating a ``QueryResult`` yields row dicts, and ``len(result)`` is the row
15
+ count, so the common cases read naturally::
16
+
17
+ for row in client.run_query("select id, name from users"):
18
+ print(row["id"], row["name"])
19
+
20
+ Attributes:
21
+ columns: Column names in selection order.
22
+ rows: One dict per row, keyed by column name.
23
+ query_id: The service-assigned statement/execution id (useful for logs).
24
+ """
25
+
26
+ columns: list[str] = field(default_factory=list)
27
+ rows: list[dict[str, Any]] = field(default_factory=list)
28
+ query_id: str = ""
29
+
30
+ def __iter__(self) -> Iterator[dict[str, Any]]:
31
+ return iter(self.rows)
32
+
33
+ def __len__(self) -> int:
34
+ return len(self.rows)
35
+
36
+ def scalar(self) -> Any:
37
+ """Return the single value of a one-row, one-column result.
38
+
39
+ Raises:
40
+ ValueError: If the result is not exactly one row and one column.
41
+ """
42
+ if len(self.rows) != 1 or len(self.columns) != 1:
43
+ raise ValueError(
44
+ f"scalar() requires exactly one row and one column, "
45
+ f"got {len(self.rows)} row(s) and {len(self.columns)} column(s)"
46
+ )
47
+ return self.rows[0][self.columns[0]]
ahi/aws/s3.py ADDED
@@ -0,0 +1,277 @@
1
+ """Upload and download local files and folders to and from S3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from boto3.exceptions import Boto3Error
13
+ from boto3.s3.transfer import TransferConfig
14
+ from botocore.exceptions import BotoCoreError, ClientError
15
+
16
+ from ahi.aws.errors import DownloadFailedError, UploadFailedError
17
+ from ahi.core import get_logger
18
+
19
+ _log = get_logger("aws.s3")
20
+
21
+
22
+ # S3 bucket naming rules (general purpose buckets).
23
+ _BUCKET_RE = re.compile(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]")
24
+
25
+ _TRANSFER_ERRORS = (ClientError, BotoCoreError, Boto3Error)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class S3Object:
30
+ """A file transferred between a local path and S3.
31
+
32
+ Attributes:
33
+ bucket: The bucket.
34
+ key: The full object key.
35
+ local_path: The local file.
36
+ size: Size in bytes.
37
+ """
38
+
39
+ bucket: str
40
+ key: str
41
+ local_path: Path
42
+ size: int
43
+
44
+ @property
45
+ def uri(self) -> str:
46
+ """The object's ``s3://bucket/key`` URI."""
47
+ return f"s3://{self.bucket}/{self.key}"
48
+
49
+
50
+ class S3Storage:
51
+ """Upload and download files under an ``s3://bucket[/prefix]`` location.
52
+
53
+ Keys and prefixes passed to the methods are relative to that location.
54
+
55
+ Args:
56
+ location: The ``s3://bucket[/prefix]`` URI.
57
+ region_name: Region for the client built when ``client`` is not given.
58
+ client: A boto3 ``s3`` client, for example ``session.client("s3")``.
59
+ transfer_config: boto3 ``TransferConfig`` for multipart and concurrency (Manual Tune).
60
+
61
+ Raises:
62
+ ValueError: Invalid location, both ``client`` and ``region_name`` given,
63
+ or no region configured.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ location: str,
69
+ *,
70
+ region_name: str | None = None,
71
+ client: Any | None = None,
72
+ transfer_config: TransferConfig | None = None,
73
+ ) -> None:
74
+ if client is not None and region_name is not None:
75
+ raise ValueError("Pass region_name or client, not both; a client keeps its own region")
76
+ self._bucket, self._prefix = _parse_s3_uri(location)
77
+ self._client = client if client is not None else _build_client(region_name)
78
+ self._transfer_config = transfer_config
79
+
80
+ def __repr__(self) -> str:
81
+ return f"S3Storage({self.location!r})"
82
+
83
+ @property
84
+ def bucket(self) -> str:
85
+ """The bucket."""
86
+ return self._bucket
87
+
88
+ @property
89
+ def prefix(self) -> str:
90
+ """The key prefix (``""`` for the bucket root)."""
91
+ return self._prefix
92
+
93
+ @property
94
+ def location(self) -> str:
95
+ """The normalised ``s3://bucket[/prefix]`` URI."""
96
+ return self._uri(self._prefix)
97
+
98
+ def upload_file(self, path: str | os.PathLike[str], key: str | None = None) -> S3Object:
99
+ """Upload one file. ``key`` defaults to the file name.
100
+
101
+ Raises:
102
+ FileNotFoundError: ``path`` is not a file.
103
+ ValueError: ``key`` is empty or has ``.`` / ``..`` segments.
104
+ UploadFailedError: The upload failed.
105
+ """
106
+ source = Path(path)
107
+ if not source.is_file():
108
+ raise FileNotFoundError(f"Not a file: {source}")
109
+ return self._upload(source, _object_key(self._prefix, source.name if key is None else key))
110
+
111
+ def upload_dir(self, path: str | os.PathLike[str], prefix: str = "") -> list[S3Object]:
112
+ """Upload every file under a folder to ``prefix``, keeping its layout.
113
+
114
+ Raises:
115
+ NotADirectoryError: ``path`` is not a folder.
116
+ OSError: A folder under ``path`` can't be read (before any upload).
117
+ UploadFailedError: An upload failed.
118
+ """
119
+ root = Path(path)
120
+ if not root.is_dir():
121
+ raise NotADirectoryError(f"Not a directory: {root}")
122
+ base = _join_key(self._prefix, prefix)
123
+ # Walk first so local errors fail before any upload; as_posix() keeps
124
+ # Windows separators out of keys.
125
+ targets = [
126
+ (file, _object_key(base, file.relative_to(root).as_posix()))
127
+ for file in _walk_files(root)
128
+ ]
129
+ uploaded = [self._upload(file, key) for file, key in targets]
130
+ _log.info("uploaded %d file(s) from %s to %s", len(uploaded), root, self._uri(base))
131
+ return uploaded
132
+
133
+ def download_file(self, key: str, path: str | os.PathLike[str]) -> S3Object:
134
+ """Download one object to ``path``, creating folders and overwriting.
135
+
136
+ Raises:
137
+ IsADirectoryError: ``path`` is a folder.
138
+ ValueError: ``key`` is empty or has ``.`` / ``..`` segments.
139
+ DownloadFailedError: The download failed (``code == "404"`` if missing).
140
+ """
141
+ dest = Path(path)
142
+ if dest.is_dir():
143
+ raise IsADirectoryError(f"Is a directory: {dest}")
144
+ return self._download(_object_key(self._prefix, key), dest)
145
+
146
+ def download_dir(self, prefix: str, path: str | os.PathLike[str]) -> list[S3Object]:
147
+ """Download every object under ``prefix`` into a folder, keeping its layout.
148
+
149
+ Raises:
150
+ NotADirectoryError: ``path`` exists and is not a folder.
151
+ DownloadFailedError: Listing or a download failed, or a key can't be
152
+ mapped to a file inside ``path``.
153
+ """
154
+ root = Path(path)
155
+ if root.exists() and not root.is_dir():
156
+ raise NotADirectoryError(f"Not a directory: {root}")
157
+ base = _join_key(self._prefix, prefix)
158
+ list_prefix = f"{base}/" if base else ""
159
+ resolved_root = root.resolve()
160
+
161
+ targets: list[tuple[str, Path]] = []
162
+ for key in self._list_keys(list_prefix):
163
+ if key.endswith("/"):
164
+ continue
165
+ relative = key[len(list_prefix) :]
166
+ targets.append((key, self._local_target(root, resolved_root, key, relative)))
167
+
168
+ downloaded = [self._download(key, dest) for key, dest in targets]
169
+ _log.info("downloaded %d file(s) from %s to %s", len(downloaded), self._uri(base), root)
170
+ return downloaded
171
+
172
+ def _upload(self, source: Path, key: str) -> S3Object:
173
+ size = source.stat().st_size
174
+ try:
175
+ self._client.upload_file(str(source), self._bucket, key, Config=self._transfer_config)
176
+ except _TRANSFER_ERRORS as exc:
177
+ reason, code = _describe(exc)
178
+ raise UploadFailedError(self._bucket, key, reason, code=code) from exc
179
+ obj = S3Object(self._bucket, key, source, size)
180
+ _log.debug("uploaded %s to %s", source, obj.uri)
181
+ return obj
182
+
183
+ def _download(self, key: str, dest: Path) -> S3Object:
184
+ dest.parent.mkdir(parents=True, exist_ok=True)
185
+ try:
186
+ self._client.download_file(self._bucket, key, str(dest), Config=self._transfer_config)
187
+ except _TRANSFER_ERRORS as exc:
188
+ reason, code = _describe(exc)
189
+ raise DownloadFailedError(self._bucket, key, reason, code=code) from exc
190
+ obj = S3Object(self._bucket, key, dest, dest.stat().st_size)
191
+ _log.debug("downloaded %s to %s", obj.uri, dest)
192
+ return obj
193
+
194
+ def _list_keys(self, prefix: str) -> list[str]:
195
+ keys: list[str] = []
196
+ try:
197
+ paginator = self._client.get_paginator("list_objects_v2")
198
+ for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
199
+ keys.extend(obj["Key"] for obj in page.get("Contents", []))
200
+ except _TRANSFER_ERRORS as exc:
201
+ reason, code = _describe(exc)
202
+ raise DownloadFailedError(self._bucket, prefix, reason, code=code) from exc
203
+ return sorted(keys)
204
+
205
+ def _local_target(self, root: Path, resolved_root: Path, key: str, relative: str) -> Path:
206
+ segments = relative.split("/")
207
+ dest = root.joinpath(*segments)
208
+ # resolve() also catches Windows drives and backslashes.
209
+ if any(s in ("", ".", "..") for s in segments) or not dest.resolve().is_relative_to(
210
+ resolved_root
211
+ ):
212
+ raise DownloadFailedError(
213
+ self._bucket, key, f"key can't be mapped to a file inside {resolved_root}"
214
+ )
215
+ return dest
216
+
217
+ def _uri(self, key: str) -> str:
218
+ return f"s3://{self._bucket}/{key}" if key else f"s3://{self._bucket}"
219
+
220
+
221
+ def _build_client(region_name: str | None) -> Any:
222
+ import boto3
223
+
224
+ session = boto3.Session(region_name=region_name)
225
+ # Prevent silent fallback to us-east-1 (aws default)
226
+ if not session.region_name:
227
+ raise ValueError(
228
+ "No AWS region configured: pass region_name=, set AWS_DEFAULT_REGION, "
229
+ "add `region` to the AWS profile, or pass a client from a session with a region"
230
+ )
231
+ return session.client("s3")
232
+
233
+
234
+ def _parse_s3_uri(uri: str) -> tuple[str, str]:
235
+ """Split ``s3://bucket[/prefix]`` into ``(bucket, prefix)``."""
236
+ scheme, sep, rest = uri.partition("://")
237
+ bucket, _, prefix = rest.partition("/")
238
+ if scheme != "s3" or not sep or not _BUCKET_RE.fullmatch(bucket):
239
+ raise ValueError(f"Expected an s3://bucket[/prefix] URI, got {uri!r}")
240
+ return bucket, _join_key(prefix)
241
+
242
+
243
+ def _join_key(*parts: str) -> str:
244
+ """Join key parts with ``/``, dropping empty segments; reject ``.`` / ``..``."""
245
+ segments = [segment for part in parts for segment in part.split("/") if segment]
246
+ if any(segment in (".", "..") for segment in segments):
247
+ raise ValueError(f"Keys and prefixes can't contain '.' or '..' segments: {parts!r}")
248
+ return "/".join(segments)
249
+
250
+
251
+ def _object_key(base: str, relative: str) -> str:
252
+ """Join ``relative`` onto ``base``; ``relative`` must not be empty."""
253
+ if not _join_key(relative):
254
+ raise ValueError(f"An object key can't be empty, got {relative!r}")
255
+ return _join_key(base, relative)
256
+
257
+
258
+ def _walk_files(root: Path) -> Iterator[Path]:
259
+ """Yield files under ``root`` in sorted order, raising on unreadable folders
260
+ (``os.walk`` skips them by default)."""
261
+ for dirpath, dirnames, filenames in os.walk(root, onerror=_raise):
262
+ dirnames.sort()
263
+ for name in sorted(filenames):
264
+ yield Path(dirpath, name)
265
+
266
+
267
+ def _raise(error: OSError) -> None:
268
+ raise error
269
+
270
+
271
+ def _describe(exc: Exception) -> tuple[str, str | None]:
272
+ """Return ``(reason, code)``. ``S3UploadFailedError`` doesn't chain its
273
+ ``ClientError``, so also look on ``__context__``."""
274
+ for candidate in (exc, exc.__context__, getattr(exc, "last_exception", None)):
275
+ if isinstance(candidate, ClientError):
276
+ return str(candidate), candidate.response.get("Error", {}).get("Code")
277
+ return str(exc), None
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: ahi-aws
3
+ Version: 0.4.0
4
+ Summary: Thin, typed boto3 helpers for AHI: AWS auth (SSO and attached-role sessions), Redshift Data API and Athena query execution, and S3 file storage.
5
+ Project-URL: Homepage, https://github.com/adaptivehealthintelligence/ahi-python-packages
6
+ Project-URL: Documentation, https://adaptivehealthintelligence.github.io/ahi-python-packages/
7
+ Project-URL: Repository, https://github.com/adaptivehealthintelligence/ahi-python-packages
8
+ Author: Adaptive Health Intelligence
9
+ License: MIT
10
+ Keywords: ahi,athena,auth,aws,boto3,redshift,s3,sso,sts
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: ahi-core<0.3,>=0.2
13
+ Requires-Dist: boto3>=1.34
14
+ Provides-Extra: types
15
+ Requires-Dist: boto3-stubs[athena,redshift-data,s3,sts]>=1.34; extra == 'types'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ahi-aws
19
+
20
+ Thin, typed `boto3` helpers for AHI. Today: AWS **auth** (one session, checked
21
+ once, with clear errors), running SQL on **Redshift** (Data API) and **Athena**,
22
+ with async query handling, and moving files and folders to and from **S3**.
23
+
24
+ ```python
25
+ from ahi.aws.auth import aws_session, check_identity
26
+
27
+ session = aws_session() # AWS_PROFILE / default profile / attached role
28
+ identity = check_identity(session) # raises an AuthError saying what to run
29
+ print(identity.arn, identity.method) # e.g. "sso", "iam-role", "container-role"
30
+ ```
31
+
32
+ ```python
33
+ from ahi.aws.redshift import RedshiftDataClient
34
+ from ahi.aws.athena import AthenaClient
35
+
36
+ rs = RedshiftDataClient(workgroup_name="analytics", database="dev")
37
+ rows = rs.run_query(
38
+ "select count(*) from events where day = :day",
39
+ parameters={"day": "2026-06-26"},
40
+ )
41
+ print(rows.scalar())
42
+
43
+ ath = AthenaClient(workgroup="primary", database="analytics")
44
+ for row in ath.run_query("select name from users where id = ?", parameters=[42]):
45
+ print(row["name"])
46
+ ```
47
+
48
+ ```python
49
+ from ahi.aws.auth import aws_session
50
+ from ahi.aws.s3 import S3Storage
51
+
52
+ session = aws_session()
53
+ storage = S3Storage("s3://my-bucket/some/prefix", client=session.client("s3"))
54
+
55
+ uploaded = storage.upload_dir("runs/run-01", prefix="run-01") # list[S3Object]
56
+ storage.download_dir("run-01", "local/run-01")
57
+ ```
58
+
59
+ ## Design notes
60
+
61
+ - **Never logs in for you.** No browser, no AWS CLI. Run `aws sso login`
62
+ beforehand (local SSO), or run where an AWS role is attached (EC2 instance
63
+ role, ECS task role); missing or expired credentials raise `AuthError`
64
+ with the command to run. No default region: set `AWS_REGION` or the
65
+ profile's `region`.
66
+ - **Parameterized queries by default.** Pass values via `parameters` (named
67
+ `:name` for Redshift, positional `?` for Athena) — never format them in.
68
+ - **One synchronous call.** `run_query` submits, polls with backoff (see
69
+ `ahi.core.poll_until`), and pages the full result set, returning a
70
+ `QueryResult` you can iterate, `len()`, or call `.scalar()` on.
71
+ - **Typed results from Redshift; strings from Athena** (Athena returns all values
72
+ as strings — we preserve that rather than guessing).
73
+ - **Failures raise** `QueryFailedError`; timeouts raise `PollTimeoutError`.
74
+ - **S3 transfers return `S3Object` records** (bucket, key, local path, size);
75
+ failures raise `UploadFailedError` / `DownloadFailedError`.
76
+
77
+ Install the optional `types` extra (`ahi-aws[types]`) for `boto3-stubs` during
78
+ development.
79
+
80
+ Part of [ahi-python-packages](https://github.com/adaptivehealthintelligence/ahi-python-packages).
@@ -0,0 +1,10 @@
1
+ ahi/aws/__init__.py,sha256=2fjWDJsepK7NKWggRPxsM_6Hf5YwImy5FH6ibVpYeZg,1071
2
+ ahi/aws/athena.py,sha256=VuuBV1bkrya7LTKQZfg0C5mPX4AMtpMQvE9q1cZGth4,6072
3
+ ahi/aws/auth.py,sha256=QquNlFN9yMTBQm-Af3sHd6D3Wipmo9u8dntyFSu6Xio,8685
4
+ ahi/aws/errors.py,sha256=s9C2xby3NA-gSiJfSQgzHGwzLgfGY6LaTWvFdyUR38I,6619
5
+ ahi/aws/redshift.py,sha256=z5M5V_55uNJu0nOcR0DPPRX2bPvLAZH7WX_iuRuSu9c,6625
6
+ ahi/aws/results.py,sha256=9VuQKYJR6fgYOfE34BWfd6L2GkefE8t-u1b7PiZLk5w,1513
7
+ ahi/aws/s3.py,sha256=kwNlxkRtfCXhm5lsXSJfnK6332mHbwuFhdkPfkH3qCs,10690
8
+ ahi_aws-0.4.0.dist-info/METADATA,sha256=6gIOndW9j-utTglMYYqgCehnpDfC29si25dyYy1Qdf8,3392
9
+ ahi_aws-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ ahi_aws-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any