Skip to content

Content Management

Once a site is created, you will want to put files on it and, from time to time, read them back or clean things up. The SiteContentManagement object is your interface for all of that.


Getting a content manager

from sites.sdk.sites import Site, Authenticator

# Option 1 — via the site object directly
site = Site.from_space_and_name(space='docs', name='my-project')
content_manager = site.get_content_manager()

# Option 2 — use a separate bearer token for the site (recommended for automation)
site_auth = Authenticator.from_token(token='<site_bearer_token>')
content_manager = site.get_content_manager(authenticator=site_auth)

# Option 3 — via SitesClient (convenient if you already have a client)
from sites.sdk import SitesClient
client = SitesClient(authenticator=hub_auth)
content_manager = client.content(site=site)

Tip: For automation, always use a per-site bearer token (Authenticator.from_token). See Authentication and Access Tokens for details.


Uploading files

Upload a single file

result = content_manager.upload(local_path='./index.html')
print(result)
# [{'message': "Path 'index.html' created.", 'status': 'OK'}]

Return type: upload() always returns list[dict] — one result dict per uploaded file — regardless of whether you upload a single file or an entire directory. This makes it safe to iterate over results without type-checking first.

By default the file is placed in the root (/) of the site. Use remote_path to put it somewhere else:

content_manager.upload(local_path='./report.pdf', remote_path='/reports/')

Upload an entire directory

content_manager.upload(local_path='./build/', recursive=True)

This walks the directory tree and uploads every file, preserving the folder hierarchy.

Filter which files to upload

Use match_pattern (a glob expression) to restrict which files are included:

# Upload only Markdown files
content_manager.upload(local_path='./docs/', match_pattern='*.md', recursive=True)

Handle files that already exist on the site

By default, an existing remote file is overwritten. You have two alternatives:

Back up the existing file first:

content_manager.upload(
    local_path='./build/',
    recursive=True,
    if_exists_backup=True        # renames the old file to <name>.bak before uploading
)

Rename the new file if a conflict exists:

content_manager.upload(
    local_path='./report.pdf',
    if_exists_rename_to='report-new.pdf'   # uploads as 'report-new.pdf' if 'report.pdf' already exists
)

Skip empty files

Zero-byte files are skipped by default. To upload them explicitly:

content_manager.upload(local_path='./static/', upload_zero_byte_files=True)

Continue on errors

If one file fails to upload and you want the rest to proceed:

content_manager.upload(local_path='./build/', recursive=True, ignore_errors=True)

Large files: Files of 100 MB or more are automatically routed through the v2 upload path, which is optimised for large payloads. You do not need to do anything special for large files.


Downloading files

Download a single file

content = content_manager.download(remote_path='index.html')
# Returns bytes
print(content)
# b'<html>...'

# Save to disk
with open('downloaded.html', 'wb') as f:
    f.write(content)

List matching files (download as a file listing)

When match_pattern is used without remote_path, the response is a dict containing a list of matching file names:

files_info = content_manager.download(match_pattern='*.pdf')
print(files_info)
# {'files': ['report-2026.pdf', 'report-2025.pdf']}

File checksums

The Sites API supports SHA-256 content digests via the Want-Repr-Digest / Repr-Digest headers (RFC 9530). The client exposes this through three features:

  1. Dedicated checksum lookup — fetch a file's SHA-256 without downloading its body.
  2. Verified downloadsdownload(..., verify=True) verifies integrity against the server-reported digest.
  3. Best-effort upload digest — if the server includes a digest header in the upload response, it is surfaced automatically.

Look up a file's checksum

checksum() sends a lightweight HEAD request and returns metadata including the SHA-256 digest:

info = content_manager.checksum(remote_path='data/forecast.grib')
print(info)
# {
#     'content_length': '52428800',
#     'content_type': 'application/octet-stream',
#     'last_modified': 'Mon, 30 Mar 2026 12:00:00 GMT',
#     'etag': 'W/"3200000-18e8b3c0"',
#     'checksum': 'RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=',
# }

The checksum value is the base64-encoded SHA-256 digest of the file contents. It is None for empty (zero-byte) files.

Tip: This is the cheapest way to detect whether a remote file has changed — compare checksums without downloading anything.

Download and verify integrity

download() supports optional checksum verification via verify=True. When enabled, the client requests the server digest, computes the SHA-256 of the downloaded body, and raises ChecksumMismatchError if it does not match:

from sites.sdk.common import ChecksumMismatchError

# Download with verification -- raises on mismatch
try:
    result = content_manager.download(
        remote_path='data/forecast.grib',
        verify=True
    )
    print('Integrity OK')
    data = result                                # bytes (or dict for JSON)
except ChecksumMismatchError as e:
    print('Corrupted download:', e)

Note: If the server does not return a Repr-Digest header (e.g., for directory listings or archive downloads), verify=True does not raise; the client logs a warning and returns the parsed content.

Deprecated: download_with_checksum() is deprecated and will be removed in the next release. Use download(..., verify=True).

Download as an archive

You can download an entire directory (or set of files) as a compressed archive using the archive parameter:

from sites.sdk.sites.utils import ArchiveFormat

# Download a directory as a ZIP archive
zip_bytes = content_manager.download(remote_path='/reports/', archive=ArchiveFormat.ZIP)
with open('reports.zip', 'wb') as f:
    f.write(zip_bytes)

# Other supported formats
tar_bytes = content_manager.download(remote_path='/data/', archive=ArchiveFormat.TAR)
tgz_bytes = content_manager.download(remote_path='/data/', archive=ArchiveFormat.TGZ)

Note: When archive is set, verify=True is automatically ignored because the server does not return a Repr-Digest header for archive responses.

Upload checksum (best-effort)

When uploading files, if the server includes a Repr-Digest header in the upload response, the digest is automatically added to the response dict under the checksum key and logged at INFO level:

result = content_manager.upload(local_path='./report.pdf')
# If the server returned a digest:
# result == [{'message': "Path 'report.pdf' created.", 'status': 'OK', 'checksum': 'RK/0qy18...'}]
# If the server did not return a digest, the 'checksum' key is simply absent.

This is a best-effort feature — the upload API is not required to return a digest, so do not rely on its presence. Use checksum() after uploading if you need a guaranteed integrity check.


Listing files

List — basic

result = content_manager.list()
for f in result.get('files', []):
    print(f['name'], f['type'])   # type: 'f' = file, 'd' = directory

List with filters

from sites.sdk.sites.utils import FileType

# List only directories
result = content_manager.list(file_type=FileType.DIR)

# List files matching a glob inside a subfolder
result = content_manager.list(remote_path='/reports/', match_pattern='*.pdf')

# List recursively
result = content_manager.list(remote_path='/', recursive=True)

FileType values:

FileType.FILE   # 'f' — regular files only
FileType.DIR    # 'd' — directories only

Pagination

When a site has many files the response is paginated. The v2 API returns a continuation_token you pass to the next call:

# First page
result = content_manager.list(limit=50)
token = result.get('continuation_token')

# Second page
while token:
    result = content_manager.list(limit=50, continuation_token=token)
    for f in result.get('files', []):
        print(f['name'])
    token = result.get('continuation_token')

Iterate lazily with list_walk

list_walk handles pagination for you automatically. It returns a generator that yields one file at a time:

for file_info in content_manager.list_walk(recursive=True):
    print(file_info['name'])

This is the recommended way to iterate over large sites without loading everything into memory at once.

# Combine with filters
for file_info in content_manager.list_walk(
    remote_path='/reports/',
    match_pattern='*.csv',
    file_type=FileType.FILE,
    recursive=True
):
    print(file_info['name'])

Deleting files and directories

Delete a single file

result = content_manager.delete(remote_path='old-report.pdf')
print(result)

Delete by pattern

# Delete all .log files at the root
content_manager.delete(match_pattern='*.log')

Delete a directory recursively

# Delete the entire /temp/ folder and everything inside it
content_manager.delete(remote_path='/temp/', recursive=True)

Warning: Deletion is permanent. There is no trash or recycle bin.


Checking site health

Before uploading or downloading, especially right after creating a site, confirm the site is ready:

from sites.sdk.common import ApiCallException
from time import sleep

while True:
    try:
        content_manager.health()
        print('Site is ready!')
        break
    except ApiCallException:
        print('Site not ready yet, retrying in 5 seconds...')
        sleep(5)

Complete worked example

from sites.sdk import SitesClient
from sites.sdk.sites import Site, Authenticator
from sites.sdk.common import ApiCallException
from time import sleep

# Authenticate and create a client
client = SitesClient(
    authenticator=Authenticator.from_credentials(username='myuser', password='mypassword')
)

# Create the site
site = Site.from_space_and_name(space='docs', name='demo')
site.quota = 5
client.create_site(site=site)

# Wait for the site to come online
content_manager = site.get_content_manager(
    authenticator=Authenticator.from_token(token='<site_bearer_token>')
)
while True:
    try:
        content_manager.health()
        break
    except ApiCallException:
        sleep(5)

# Upload a directory
content_manager.upload(local_path='./website/', recursive=True, if_exists_backup=True)

# List everything that was uploaded
for f in content_manager.list_walk(recursive=True):
    print(f['name'])

# Download a file and print it
content = content_manager.download(remote_path='index.html')
print(content.decode())

# Clean up temporary files
content_manager.delete(remote_path='/tmp/', recursive=True)

What's next?

  • Access Tokens — generate per-site tokens for automation scripts
  • Signed URLs — share individual files with time-limited public links
  • Symlinks — create shortcuts inside your site
  • Advanced Topics — clone an entire site, set up CORS, and more