interloper-aws 0.94.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """Interloper AWS integration: Amazon S3 destination and connection."""
2
+
3
+ from interloper_aws.connection import AWSConnection
4
+ from interloper_aws.s3 import S3Destination
5
+
6
+ __all__ = [
7
+ "AWSConnection",
8
+ "S3Destination",
9
+ ]
@@ -0,0 +1,102 @@
1
+ """AWS connection resource holding IAM credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from typing import Any
7
+
8
+ import boto3
9
+ from interloper.connection import Connection, connection
10
+ from interloper.resource.fields import InputField, SecretField, fetch_field_provider
11
+ from pydantic_settings import SettingsConfigDict
12
+
13
+
14
+ @connection(
15
+ key="aws_connection",
16
+ name="AWS",
17
+ icon="icon:aws",
18
+ tags=["Cloud"],
19
+ )
20
+ class AWSConnection(Connection):
21
+ """Connection resource holding AWS credentials.
22
+
23
+ With an access key pair, every client signs with it (plus the session
24
+ token, for temporary credentials). Without one, boto3's default
25
+ credential chain applies: environment, shared config, then the instance
26
+ or pod role (EC2 instance profile, EKS IRSA or Pod Identity), so an
27
+ in-cluster deployment needs no stored secret at all. Every field also
28
+ loads from the standard environment variables (``AWS_ACCESS_KEY_ID``,
29
+ ``AWS_SECRET_ACCESS_KEY``, ``AWS_SESSION_TOKEN``, ``AWS_REGION``).
30
+ """
31
+
32
+ model_config = SettingsConfigDict(env_prefix="aws_")
33
+
34
+ access_key_id: str | None = InputField(
35
+ default=None,
36
+ label="Access key ID",
37
+ description="IAM access key ID; leave empty to use the ambient role",
38
+ )
39
+ secret_access_key: str | None = SecretField(
40
+ default=None,
41
+ label="Secret access key",
42
+ description="IAM secret access key",
43
+ )
44
+ session_token: str | None = SecretField(
45
+ default=None,
46
+ label="Session token",
47
+ description="Session token, for temporary credentials only",
48
+ )
49
+ region: str = InputField(default="eu-central-1", description="AWS region clients are built for")
50
+
51
+ @cached_property
52
+ def session(self) -> boto3.session.Session:
53
+ """The boto3 session every client is built from.
54
+
55
+ Unset credentials are passed as ``None``, which leaves boto3 to
56
+ resolve them through its default chain.
57
+
58
+ Returns:
59
+ The session, cached per connection instance.
60
+ """
61
+ return boto3.session.Session(
62
+ aws_access_key_id=self.access_key_id,
63
+ aws_secret_access_key=self.secret_access_key,
64
+ aws_session_token=self.session_token,
65
+ region_name=self.region,
66
+ )
67
+
68
+ def client(self, service: str) -> Any:
69
+ """Build a client for one AWS service from the session.
70
+
71
+ Args:
72
+ service: The boto3 service name, such as ``"s3"`` or ``"sts"``.
73
+
74
+ Returns:
75
+ The service client.
76
+ """
77
+ return self.session.client(service)
78
+
79
+ @fetch_field_provider
80
+ def buckets(self) -> list[dict[str, str]]:
81
+ """List the S3 buckets the credentials can see.
82
+
83
+ Backs the S3 destination's ``bucket`` ``FetchField``.
84
+
85
+ Returns:
86
+ Bucket options with ``name``, sorted case-insensitively.
87
+ """
88
+ response = self.client("s3").list_buckets()
89
+ results = [{"name": bucket["Name"]} for bucket in response.get("Buckets", [])]
90
+ return sorted(results, key=lambda b: b["name"].lower())
91
+
92
+ def check(self) -> bool:
93
+ """Prove the credentials work via STS ``GetCallerIdentity``.
94
+
95
+ The call needs no IAM permission, so it isolates a bad credential
96
+ from a missing grant.
97
+
98
+ Returns:
99
+ True; invalid credentials raise out of the call instead.
100
+ """
101
+ self.client("sts").get_caller_identity()
102
+ return True
@@ -0,0 +1,7 @@
1
+ """Amazon S3 destination implementations."""
2
+
3
+ from interloper_aws.s3.destination import S3Destination
4
+
5
+ __all__ = [
6
+ "S3Destination",
7
+ ]
@@ -0,0 +1,118 @@
1
+ """Amazon S3 destination implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from functools import cached_property
7
+ from typing import Any
8
+
9
+ from botocore.exceptions import ClientError
10
+ from interloper.destination import ObjectStoreDestination, StoredObject, destination
11
+ from interloper.resource.fields import FetchField
12
+
13
+ from interloper_aws.connection import AWSConnection
14
+
15
+
16
+ @destination(
17
+ key="s3_destination",
18
+ name="Amazon S3",
19
+ icon="icon:s3",
20
+ tags=["Cloud"],
21
+ )
22
+ class S3Destination(ObjectStoreDestination):
23
+ """Amazon S3 destination.
24
+
25
+ An :class:`~interloper.destination.ObjectStoreDestination` over S3: the
26
+ base owns the hive layout, the formats and the row-count metadata, so
27
+ objects land at::
28
+
29
+ s3://{bucket}/{prefix}/{dataset}/{table}/data.{ext}
30
+ s3://{bucket}/{prefix}/{dataset}/{table}/{column}={partition}/data.{ext}
31
+ """
32
+
33
+ connection: AWSConnection
34
+
35
+ bucket: str = FetchField(
36
+ provider="connection.buckets",
37
+ label_key="name",
38
+ value_key="name",
39
+ description="S3 bucket",
40
+ discriminator=True,
41
+ )
42
+
43
+ @cached_property
44
+ def client(self) -> Any:
45
+ """The S3 client every read and write goes through.
46
+
47
+ Returns:
48
+ The client, cached per destination instance.
49
+ """
50
+ return self.connection.client("s3")
51
+
52
+ # -- Object hooks ----------------------------------------------------------
53
+
54
+ def put_object(self, name: str, payload: bytes, content_type: str, metadata: dict[str, str]) -> None:
55
+ """Upload an object, overwriting any object of the same key.
56
+
57
+ Args:
58
+ name: The object key.
59
+ payload: The object's bytes.
60
+ content_type: The payload's media type.
61
+ metadata: User metadata to store with the object.
62
+ """
63
+ self.client.put_object(
64
+ Bucket=self.bucket,
65
+ Key=name,
66
+ Body=payload,
67
+ ContentType=content_type,
68
+ Metadata=metadata,
69
+ )
70
+
71
+ def get_object(self, name: str) -> bytes | None:
72
+ """Download an object.
73
+
74
+ Args:
75
+ name: The object key.
76
+
77
+ Returns:
78
+ The object's bytes, or ``None`` when no object has that key.
79
+
80
+ Raises:
81
+ ClientError: For any S3 error other than a missing key.
82
+ """
83
+ try:
84
+ response = self.client.get_object(Bucket=self.bucket, Key=name)
85
+ except ClientError as error:
86
+ if error.response.get("Error", {}).get("Code") == "NoSuchKey":
87
+ return None
88
+ raise
89
+ return response["Body"].read()
90
+
91
+ def list_objects(self, prefix: str) -> Iterator[StoredObject]:
92
+ """List the objects under a key prefix, with their user metadata.
93
+
94
+ ``ListObjectsV2`` carries no user metadata, so each listed key is
95
+ followed by a ``HeadObject``.
96
+
97
+ Args:
98
+ prefix: The key prefix, ending with ``/``.
99
+
100
+ Yields:
101
+ One stored object per key.
102
+ """
103
+ paginator = self.client.get_paginator("list_objects_v2")
104
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
105
+ for listed in page.get("Contents", []):
106
+ head = self.client.head_object(Bucket=self.bucket, Key=listed["Key"])
107
+ yield StoredObject(name=listed["Key"], metadata=dict(head.get("Metadata", {})))
108
+
109
+ def object_uri(self, name: str) -> str:
110
+ """Return the ``s3://`` URI of an object.
111
+
112
+ Args:
113
+ name: The object key.
114
+
115
+ Returns:
116
+ ``s3://{bucket}/{name}``.
117
+ """
118
+ return f"s3://{self.bucket}/{name}"
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-aws
3
+ Version: 0.94.0
4
+ Summary: Interloper AWS integration: Amazon S3 destination and connection
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: interloper-core
8
+ Requires-Dist: boto3>=1.34
9
+ Requires-Dist: pyarrow>=14
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # interloper-aws
14
+
15
+ AWS integration for interloper: an `S3Destination` that stores asset data in
16
+ an Amazon S3 bucket, and the `AWSConnection` that holds the credentials.
17
+
18
+ `S3Destination` is an `ObjectStoreDestination`, the same base as the Cloud
19
+ Storage destination in `interloper-google-cloud`, so the two behave
20
+ identically: one object per partition in a hive-partitioned layout, Parquet
21
+ (the default), JSONL or CSV, the partition column in the path only, and each
22
+ object's row count stored as `row_count` user metadata.
23
+
24
+ ```
25
+ s3://{bucket}/{prefix}/{dataset}/{table}/data.{ext}
26
+ s3://{bucket}/{prefix}/{dataset}/{table}/{column}={partition}/data.{ext}
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import interloper as il
33
+ from interloper_aws import AWSConnection, S3Destination
34
+
35
+ destination = S3Destination(
36
+ connection=AWSConnection(region="eu-central-1"),
37
+ bucket="acme-lake",
38
+ prefix="raw",
39
+ format="parquet",
40
+ )
41
+
42
+ source = Shop(destinations=destination)
43
+ ```
44
+
45
+ ## Credentials
46
+
47
+ The connection takes an access key pair (`access_key_id`,
48
+ `secret_access_key`, plus `session_token` for temporary credentials) and a
49
+ `region`, defaulting to `eu-central-1`. Each field also loads from the
50
+ standard environment variables: `AWS_ACCESS_KEY_ID`,
51
+ `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` and `AWS_REGION`.
52
+
53
+ Leave the key pair empty and boto3's default credential chain applies:
54
+ environment, shared config files, then the instance or pod role (an EC2
55
+ instance profile, EKS IRSA or Pod Identity). In a cluster, that means no
56
+ stored secret at all: grant the workload's role access to the bucket.
57
+
58
+ The connection's check calls STS `GetCallerIdentity`, which needs no IAM
59
+ permission, so a failing check means the credentials themselves are wrong.
60
+ The bucket picker calls `ListBuckets`.
61
+
62
+ ## Permissions
63
+
64
+ The destination needs, on the bucket and its objects:
65
+
66
+ - `s3:PutObject` to write
67
+ - `s3:GetObject` to read, and to count objects written without `row_count`
68
+ metadata
69
+ - `s3:ListBucket` for partition row counts
70
+ - `s3:ListAllMyBuckets` for the bucket picker only
71
+
72
+ ## Notes
73
+
74
+ `ListObjectsV2` returns no user metadata, so partition row counts issue one
75
+ `HeadObject` per listed object. That is still no download: the count comes
76
+ from the `row_count` metadata stamped at write time. Objects written by other
77
+ tools, without that metadata, are downloaded and counted.
@@ -0,0 +1,8 @@
1
+ interloper_aws/__init__.py,sha256=G_MrENepSvDpTWlIJA7SeI8si3BNWIotErPhcy72L98,226
2
+ interloper_aws/connection.py,sha256=PYZOywxYVjPn8qcbBbHJ1mXJwYR5Uet4rFUy2dPwGDw,3482
3
+ interloper_aws/s3/__init__.py,sha256=089BFf-NSwIT2TCrnt5gRSa5wX3Xn7ShRxLW4ltk5cU,138
4
+ interloper_aws/s3/destination.py,sha256=2KLKdnwE2w_1DLuPmJk_5MGpwMXaZtAaXP9QDkAY9PA,3674
5
+ interloper_aws-0.94.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
6
+ interloper_aws-0.94.0.dist-info/entry_points.txt,sha256=s7V1D0SDGsTFcB5_938HG3SLqnrDTMPN5piZO-rVBf0,46
7
+ interloper_aws-0.94.0.dist-info/METADATA,sha256=mhE55nPgG2pNQiwVep1UGhDMjCbBoEmSm0xiXo6U1L0,2705
8
+ interloper_aws-0.94.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [interloper.components]
2
+ aws = interloper_aws
3
+