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 +44 -0
- ahi/aws/athena.py +167 -0
- ahi/aws/auth.py +227 -0
- ahi/aws/errors.py +202 -0
- ahi/aws/redshift.py +175 -0
- ahi/aws/results.py +47 -0
- ahi/aws/s3.py +277 -0
- ahi_aws-0.4.0.dist-info/METADATA +80 -0
- ahi_aws-0.4.0.dist-info/RECORD +10 -0
- ahi_aws-0.4.0.dist-info/WHEEL +4 -0
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,,
|