boto3 S3 helper with paginated listing and presigned URLs, tested offline with moto: working versions (tested 2026-10-15)
A small boto3 S3 helper for upload, paginated listing, presigned GET URLs and batched delete, tested offline with moto's mock_aws and fake credentials.
passed4 passed · 0 failed2026-10-15 13:27 UTC- Status
- passed
- Last passed
- 2026-10-15 13:27 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions3
boto3@1.43.114moto@5.2.4pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("boto3-moto-s3-presigned") (install), or see them on GitHub.
requirements.txt
boto3==1.43.114 moto==5.2.4 pytest==9.1.1
s3_helper.py
import boto3
from botocore.config import Config
# SigV4 is required for X-Amz-Expires in presigned URLs. The default signer for S3 is SigV2 in some setups.
SIGV4 = Config(signature_version="s3v4")
class S3Helper:
def __init__(self, bucket: str, region: str = "us-east-1"):
self.bucket = bucket
self.s3 = boto3.client("s3", region_name=region, config=SIGV4)
def upload(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> None:
self.s3.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type)
def list_keys(self, prefix: str = "", page_size: int = 1000) -> list[str]:
# list_objects_v2 returns at most 1000 keys per page; the paginator follows continuation tokens.
pages = self.s3.get_paginator("list_objects_v2").paginate(
Bucket=self.bucket, Prefix=prefix, PaginationConfig={"PageSize": page_size}
)
return [obj["Key"] for page in pages for obj in page.get("Contents", [])]
def presigned_url(self, key: str, expires: int = 300) -> str:
# Signing is local, so no network call is made.
return self.s3.generate_presigned_url(
"get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=expires
)
def delete_prefix(self, prefix: str) -> int:
keys = self.list_keys(prefix)
# delete_objects rejects an empty Objects list and accepts at most 1000 keys per call.
for i in range(0, len(keys), 1000):
batch = [{"Key": k} for k in keys[i:i + 1000]]
self.s3.delete_objects(Bucket=self.bucket, Delete={"Objects": batch, "Quiet": True})
return len(keys)
def create_bucket(bucket: str, region: str = "us-east-1") -> None:
s3 = boto3.client("s3", region_name=region)
# us-east-1 rejects a LocationConstraint; every other region requires one.
if region == "us-east-1":
s3.create_bucket(Bucket=bucket)
else:
s3.create_bucket(Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": region})
tests/test_s3_helper.py
from urllib.parse import parse_qs, urlsplit
import pytest
from moto import mock_aws
from s3_helper import S3Helper, create_bucket
BUCKET = "demo-bucket"
@pytest.fixture(autouse=True)
def fake_aws(monkeypatch):
# Fake credentials so a broken test can never reach a real AWS account.
for var in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"):
monkeypatch.setenv(var, "testing")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.delenv("AWS_PROFILE", raising=False)
@mock_aws
def test_upload_and_list_paginates():
create_bucket(BUCKET)
h = S3Helper(BUCKET)
for i in range(5):
h.upload(f"docs/{i}.txt", b"x", content_type="text/plain")
# page_size=2 forces three pages, so the paginator must follow tokens.
assert h.list_keys("docs/", page_size=2) == [f"docs/{i}.txt" for i in range(5)]
assert h.s3.head_object(Bucket=BUCKET, Key="docs/0.txt")["ContentType"] == "text/plain"
@mock_aws
def test_presigned_url_has_expiry():
create_bucket(BUCKET)
url = S3Helper(BUCKET).presigned_url("docs/0.txt", expires=300)
assert url.startswith(f"https://{BUCKET}.s3")
# The URL is only valid inside moto's mock, so we check its query parameters rather than fetching it.
query = parse_qs(urlsplit(url).query)
assert query["X-Amz-Expires"] == ["300"]
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
@mock_aws
def test_delete_prefix_only_touches_matching_keys():
create_bucket(BUCKET)
h = S3Helper(BUCKET)
for key in ["logs/a.txt", "logs/b.txt", "keep/c.txt"]:
h.upload(key, b"x")
assert h.delete_prefix("logs/") == 2
assert h.list_keys() == ["keep/c.txt"]
@mock_aws
def test_delete_empty_prefix_is_noop():
# delete_objects with an empty Objects list raises MalformedXML, so the helper must skip the call.
create_bucket(BUCKET)
assert S3Helper(BUCKET).delete_prefix("nothing/") == 0
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.