Read and Write

Read and write one S3 object: bytes and text, metadata and tags on write, conditional writes, empty objects and folder markers, file objects with open (JSON, CSV, gzip, streaming), and uploading local files.

Setup

This cell only prepares the notebook: it connects to the S3 bucket that the s3pathlib CI uses and empties the folder of this chapter, so every run starts from the same state. In your own code you only need the two lines marked below, with your own AWS profile and bucket.

[1]:
import os

import boto3
from s3pathlib.api import S3Path
from s3pathlib.api import s3_context

boto_ses = boto3.Session(profile_name=os.environ["S3PATHLIB_TEST_AWS_PROFILE"])
s3_context.attach_s3_client(boto_ses.client("s3"))  # <-- in your code
aws_account_id = boto_ses.client("sts").get_caller_identity()["Account"]
bucket = f"{aws_account_id}-{boto_ses.region_name}-s3pathlib-test"  # <-- your bucket

# every demo of this chapter writes under this folder; start from empty
s3dir_chapter = S3Path(bucket, "projects/s3pathlib/docs/03-Read-and-Write/")
s3dir_chapter.delete()
print(s3dir_chapter.uri)
s3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/

1. Bytes and text

write_bytes replaces the object if it exists, and returns a new S3Path with a snapshot of what was written, so reading its size costs no AWS call. The path you called it on does not change; see Metadata and Tags for the snapshot model.

[2]:
s3dir = s3dir_chapter / "bytes-and-text/"
p = s3dir / "hello.txt"
p_new = p.write_bytes(b"Hello, S3!")
print(f"{p_new.size = }")
p_new.size = 10
[3]:
p.read_bytes()
[3]:
b'Hello, S3!'

write_text and read_text do the same with a str:

[4]:
p.write_text("Hello again!")
p.read_text()
[4]:
'Hello again!'

encoding works as in str.encode / bytes.decode:

[5]:
p = s3dir / "greeting.txt"
p.write_text("你好", encoding="utf-16")
p.read_text(encoding="utf-16")
[5]:
'你好'

range reads part of the object, with the syntax of the HTTP Range header (both ends included):

[6]:
p = s3dir / "hello.txt"
p.read_bytes(range="bytes=0-4")
[6]:
b'Hello'

Reading a missing object raises boto3’s own error as is (NoSuchKey). Catch it as botocore.exceptions.ClientError and check the error code:

[7]:
import botocore.exceptions

try:
    (s3dir / "missing.txt").read_text()
except botocore.exceptions.ClientError as e:
    print(e.response["Error"]["Code"])
NoSuchKey

2. Metadata, tags and headers

The other arguments of write_bytes / write_text are the arguments of boto3’s put_object in snake case. metadata and tags take a plain dict.

ContentTypeEnum has the common content types, as strings; with one of them, the “Open” button of the S3 console shows the object in the browser instead of downloading it.

[8]:
from s3pathlib.api import ContentTypeEnum

s3dir = s3dir_chapter / "metadata-and-tags/"
p = s3dir / "report.json"
p_new = p.write_text(
    '{"total": 42}',
    content_type=ContentTypeEnum.app_json,
    metadata={"author": "alice"},
    tags={"project": "docs"},
)
p_new.metadata
[8]:
{'author': 'alice'}

The content type is in the head_object response:

[9]:
response = p.head_object()
response["ContentType"]
[9]:
'application/json'

get_tags returns the version id (None without versioning) and the tags:

[10]:
version_id, tags = p.get_tags()
tags
[10]:
{'project': 'docs'}

S3 stores the keys of user metadata in lower case, so {"Author": ...} comes back as {"author": ...}; s3pathlib warns when you write an upper case key. More in Metadata and Tags.

3. Conditional writes

if_none_match="*" only writes when the object does not exist, in one atomic call: when two processes write at the same time, only one succeeds.

[11]:
s3dir = s3dir_chapter / "conditional-writes/"
p = s3dir / "lock.txt"
p.write_text("owner: job-1", if_none_match="*")
p.read_text()
[11]:
'owner: job-1'

The second write fails with PreconditionFailed, and the object keeps the first content:

[12]:
try:
    p.write_text("owner: job-2", if_none_match="*")
except botocore.exceptions.ClientError as e:
    print(e.response["Error"]["Code"])
PreconditionFailed
[13]:
p.read_text()
[13]:
'owner: job-1'

if_match=<etag> only replaces the object if nobody changed it since you last saw it (optimistic locking). Keep the ETag of what you wrote:

[14]:
p = s3dir / "counter.txt"
p_new = p.write_text("1")
etag = p_new.etag
etag
[14]:
'c4ca4238a0b923820dcc509a6f75849b'

Someone else writes in between:

[15]:
p.write_text("100")
[15]:
S3Path('s3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/conditional-writes/counter.txt')

Our write with the old ETag fails, and the other write is kept:

[16]:
try:
    p.write_text("2", if_match=etag)
except botocore.exceptions.ClientError as e:
    print(e.response["Error"]["Code"])
PreconditionFailed
[17]:
p.read_text()
[17]:
'100'

4. Empty objects and folder markers

touch creates an empty object:

[18]:
s3dir = s3dir_chapter / "touch-and-mkdir/"
p = s3dir / "_SUCCESS"
p.touch()
p.read_bytes()
[18]:
b''

It never changes an existing object; it raises S3FileAlreadyExist, or does nothing with exist_ok=True:

[19]:
from s3pathlib.api import exc

try:
    p.touch()
except exc.S3FileAlreadyExist as e:
    print(f"{type(e).__name__}: {e}")
S3FileAlreadyExist: S3 object 's3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/touch-and-mkdir/_SUCCESS' already exists!
[20]:
p.touch(exist_ok=True)

S3 has no folders. A folder “exists” as soon as one key starts with it. mkdir writes a folder marker, the empty object folder/ that “Create folder” makes in the S3 console, so that an empty folder shows up.

[21]:
s3dir_new = s3dir / "a/b/c/"
s3dir_new.exists()
[21]:
False

parents=True also writes the markers of the parent folders:

[22]:
s3dir_new.mkdir(parents=True)
s3dir_new.exists()
[22]:
True

iter_objects skips folder markers, so it only lists _SUCCESS:

[23]:
for p in s3dir.iter_objects():
    print(p.uri)
s3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/touch-and-mkdir/_SUCCESS

count_objects counts them with include_folder=True: _SUCCESS and the markers touch-and-mkdir/, a/, a/b/ and a/b/c/.

[24]:
s3dir.count_objects(include_folder=True)
[24]:
5

Note that parents=True writes the marker of every parent up to the bucket (here also projects/, projects/s3pathlib/, …), not only of the folders that did not exist: in S3 a folder without marker “exists” too.

mkdir on an existing folder raises S3FolderAlreadyExist, unless exist_ok=True:

[25]:
try:
    s3dir_new.mkdir()
except exc.S3FolderAlreadyExist as e:
    print(f"{type(e).__name__}: {e}")
S3FolderAlreadyExist: S3 folder 's3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/touch-and-mkdir/a/b/c/' already exists!

5. Exists

exists works on a file:

[26]:
s3dir = s3dir_chapter / "exists/"
p = s3dir / "data.csv"
p.write_text("a,b")
p.exists()
[26]:
True

On a folder (any key under it, a folder marker included):

[27]:
s3dir.exists()
[27]:
True
[28]:
(s3dir / "missing/").exists()
[28]:
False

And on a bucket:

[29]:
S3Path(bucket).exists()
[29]:
True

ensure_not_exists raises if the path exists, handy before an operation that must not replace anything:

[30]:
try:
    p.ensure_not_exists()
except exc.S3FileAlreadyExist as e:
    print(f"{type(e).__name__}: {e}")
S3FileAlreadyExist: S3 object 's3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/exists/data.csv' already exists!

6. File objects with open

open returns a file object (through smart_open), so any library that reads or writes files works with S3. The content is streamed: a big object is never loaded in memory at once. Always use it in a with block; a write is only finished when the file is closed.

[31]:
import json

s3dir = s3dir_chapter / "open/"
p = s3dir / "config.json"
with p.open("w") as f:
    json.dump({"name": "alice", "tags": ["a", "b"]}, f)
[32]:
with p.open("r") as f:
    config = json.load(f)
config
[32]:
{'name': 'alice', 'tags': ['a', 'b']}

Write a log file line by line:

[33]:
p = s3dir / "events.log"
with p.open("w") as f:
    for i in range(1000):
        f.write(f"event {i}\n")

And read it line by line; only a small part of the file is in memory at any time:

[34]:
n_lines = 0
with p.open("r") as f:
    for line in f:
        n_lines += 1
n_lines
[34]:
1000

The csv module needs newline="", as with local files:

[35]:
import csv

p = s3dir / "users.csv"
with p.open("w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["id", "name"])
    writer.writerow([1, "alice"])
[36]:
with p.open("r", newline="") as f:
    for row in csv.DictReader(f):
        print(row)
{'id': '1', 'name': 'alice'}

Compression follows the extension. Writing *.gz (or .bz2, .zst, …) writes compressed data, and reading it gives the uncompressed content.

[37]:
p = s3dir / "data.json.gz"
with p.open("w") as f:
    json.dump({"compressed": True}, f)
[38]:
with p.open("r") as f:
    data = json.load(f)
data
[38]:
{'compressed': True}

read_bytes shows what is really stored: gzip data starts with the bytes 1f 8b.

[39]:
p.read_bytes()[:2].hex()
[39]:
'1f8b'

compression="disable" reads or writes the bytes as they are:

[40]:
with p.open("rb", compression="disable") as f:
    first_bytes = f.read(2)
first_bytes.hex()
[40]:
'1f8b'

If an exception is raised inside the with block of a write, the upload is aborted and nothing is written, so there is never a half written object:

[41]:
p = s3dir / "half-written.txt"
try:
    with p.open("w") as f:
        f.write("first part")
        raise RuntimeError("the job failed")
except RuntimeError:
    print("the job failed")
the job failed
[42]:
p.exists()
[42]:
False

open also takes the write arguments of write_bytes (metadata, tags, content_type, …). By default it uploads in parts while you write (multipart upload); multipart_upload=False keeps the data in memory and sends it with one put_object when the file is closed, which is required for the arguments that only put_object has, such as if_none_match.

7. Upload local files

Create a few local files to upload:

[43]:
import tempfile
from pathlib import Path

tmp = tempfile.TemporaryDirectory()
dir_local = Path(tmp.name)
(dir_local / "report.txt").write_text("local report")
(dir_local / "logs").mkdir()
(dir_local / "logs" / "1.log").write_text("log 1")
(dir_local / "logs" / "2.log").write_text("log 2")
[43]:
5

upload_file uploads one local file (with boto3’s managed transfer, in parts for a big file) and returns the new S3Path:

[44]:
s3dir = s3dir_chapter / "upload/"
p = s3dir / "report.txt"
p_new = p.upload_file(dir_local / "report.txt")
p_new.read_text()
[44]:
'local report'

Like the copy methods, it does not replace an existing object unless you pass overwrite=True:

[45]:
try:
    p.upload_file(dir_local / "report.txt")
except exc.S3FileAlreadyExist as e:
    print(f"{type(e).__name__}: {e}")
S3FileAlreadyExist: S3 object 's3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/upload/report.txt' already exists!

upload_dir uploads every file of a local directory (or the files matching a glob pattern), keeping their relative paths, and returns how many it uploaded. With overwrite=False (the default) it checks every target first and uploads nothing if one exists.

[46]:
s3dir_backup = s3dir / "backup/"
s3dir_backup.upload_dir(dir_local, pattern="**/*.log")
[46]:
2
[47]:
for p in s3dir_backup.iter_objects():
    print(p.uri)
s3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/upload/backup/logs/1.log
s3://123456789012-us-east-1-s3pathlib-test/projects/s3pathlib/docs/03-Read-and-Write/upload/backup/logs/2.log
[48]:
tmp.cleanup()

Common pitfalls

  • Writes return a new path. p.write_text(...) does not update the snapshot of p; use the returned path to read size / etag of what you wrote.

  • Opening without a with block. The object is only written when the file is closed.

  • Compression by extension. open on data.gz decompresses; pass compression="disable" to get the raw bytes.

  • Conditional writes with open need multipart_upload=False, otherwise ValueError.

See also