interloper-aws 0.94.0__tar.gz
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.
- interloper_aws-0.94.0/PKG-INFO +77 -0
- interloper_aws-0.94.0/README.md +65 -0
- interloper_aws-0.94.0/pyproject.toml +60 -0
- interloper_aws-0.94.0/pyproject.toml.orig +42 -0
- interloper_aws-0.94.0/src/interloper_aws/__init__.py +9 -0
- interloper_aws-0.94.0/src/interloper_aws/connection.py +102 -0
- interloper_aws-0.94.0/src/interloper_aws/s3/__init__.py +7 -0
- interloper_aws-0.94.0/src/interloper_aws/s3/destination.py +118 -0
|
@@ -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,65 @@
|
|
|
1
|
+
# interloper-aws
|
|
2
|
+
|
|
3
|
+
AWS integration for interloper: an `S3Destination` that stores asset data in
|
|
4
|
+
an Amazon S3 bucket, and the `AWSConnection` that holds the credentials.
|
|
5
|
+
|
|
6
|
+
`S3Destination` is an `ObjectStoreDestination`, the same base as the Cloud
|
|
7
|
+
Storage destination in `interloper-google-cloud`, so the two behave
|
|
8
|
+
identically: one object per partition in a hive-partitioned layout, Parquet
|
|
9
|
+
(the default), JSONL or CSV, the partition column in the path only, and each
|
|
10
|
+
object's row count stored as `row_count` user metadata.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
s3://{bucket}/{prefix}/{dataset}/{table}/data.{ext}
|
|
14
|
+
s3://{bucket}/{prefix}/{dataset}/{table}/{column}={partition}/data.{ext}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import interloper as il
|
|
21
|
+
from interloper_aws import AWSConnection, S3Destination
|
|
22
|
+
|
|
23
|
+
destination = S3Destination(
|
|
24
|
+
connection=AWSConnection(region="eu-central-1"),
|
|
25
|
+
bucket="acme-lake",
|
|
26
|
+
prefix="raw",
|
|
27
|
+
format="parquet",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
source = Shop(destinations=destination)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Credentials
|
|
34
|
+
|
|
35
|
+
The connection takes an access key pair (`access_key_id`,
|
|
36
|
+
`secret_access_key`, plus `session_token` for temporary credentials) and a
|
|
37
|
+
`region`, defaulting to `eu-central-1`. Each field also loads from the
|
|
38
|
+
standard environment variables: `AWS_ACCESS_KEY_ID`,
|
|
39
|
+
`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` and `AWS_REGION`.
|
|
40
|
+
|
|
41
|
+
Leave the key pair empty and boto3's default credential chain applies:
|
|
42
|
+
environment, shared config files, then the instance or pod role (an EC2
|
|
43
|
+
instance profile, EKS IRSA or Pod Identity). In a cluster, that means no
|
|
44
|
+
stored secret at all: grant the workload's role access to the bucket.
|
|
45
|
+
|
|
46
|
+
The connection's check calls STS `GetCallerIdentity`, which needs no IAM
|
|
47
|
+
permission, so a failing check means the credentials themselves are wrong.
|
|
48
|
+
The bucket picker calls `ListBuckets`.
|
|
49
|
+
|
|
50
|
+
## Permissions
|
|
51
|
+
|
|
52
|
+
The destination needs, on the bucket and its objects:
|
|
53
|
+
|
|
54
|
+
- `s3:PutObject` to write
|
|
55
|
+
- `s3:GetObject` to read, and to count objects written without `row_count`
|
|
56
|
+
metadata
|
|
57
|
+
- `s3:ListBucket` for partition row counts
|
|
58
|
+
- `s3:ListAllMyBuckets` for the bucket picker only
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
`ListObjectsV2` returns no user metadata, so partition row counts issue one
|
|
63
|
+
`HeadObject` per listed object. That is still no download: the count comes
|
|
64
|
+
from the `row_count` metadata stamped at write time. Objects written by other
|
|
65
|
+
tools, without that metadata, are downloaded and counted.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "interloper-aws"
|
|
3
|
+
version = "0.94.0"
|
|
4
|
+
description = "Interloper AWS integration: Amazon S3 destination and connection"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"interloper-core",
|
|
9
|
+
"boto3>=1.34",
|
|
10
|
+
"pyarrow>=14",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Guillaume Onfroy"
|
|
15
|
+
email = "guillaume@digitlcloud.com"
|
|
16
|
+
|
|
17
|
+
[project.entry-points."interloper.components"]
|
|
18
|
+
aws = "interloper_aws"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.12.9,<0.13"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources.interloper-core]
|
|
25
|
+
workspace = true
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 120
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
preview = true
|
|
32
|
+
extend-select = [
|
|
33
|
+
"E",
|
|
34
|
+
"I",
|
|
35
|
+
"UP",
|
|
36
|
+
"ANN001",
|
|
37
|
+
"ANN201",
|
|
38
|
+
"ANN202",
|
|
39
|
+
"DOC",
|
|
40
|
+
"D",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.pydocstyle]
|
|
44
|
+
convention = "google"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint.per-file-ignores]
|
|
47
|
+
"__init__.py" = [
|
|
48
|
+
"F401",
|
|
49
|
+
"F403",
|
|
50
|
+
]
|
|
51
|
+
"tests/**" = [
|
|
52
|
+
"ANN",
|
|
53
|
+
"F811",
|
|
54
|
+
"D101",
|
|
55
|
+
"D102",
|
|
56
|
+
"D103",
|
|
57
|
+
"D104",
|
|
58
|
+
"RUF069",
|
|
59
|
+
"PLW0108",
|
|
60
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ###############
|
|
2
|
+
# PROJECT / UV
|
|
3
|
+
# ###############
|
|
4
|
+
[project]
|
|
5
|
+
name = "interloper-aws"
|
|
6
|
+
version = "0.94.0"
|
|
7
|
+
description = "Interloper AWS integration: Amazon S3 destination and connection"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"interloper-core",
|
|
13
|
+
"boto3>=1.34",
|
|
14
|
+
"pyarrow>=14",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."interloper.components"]
|
|
18
|
+
aws = "interloper_aws"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.12.9,<0.13"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
interloper-core = { workspace = true }
|
|
26
|
+
|
|
27
|
+
# ###############
|
|
28
|
+
# RUFF
|
|
29
|
+
# ###############
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 120
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
preview = true
|
|
35
|
+
extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.pydocstyle]
|
|
38
|
+
convention = "google"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint.per-file-ignores]
|
|
41
|
+
"__init__.py" = ["F401", "F403"]
|
|
42
|
+
"tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104", "RUF069", "PLW0108"]
|
|
@@ -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,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}"
|