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 returnslist[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:
Upload an entire directory¶
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:
Continue on errors¶
If one file fails to upload and you want the rest to proceed:
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:
- Dedicated checksum lookup — fetch a file's SHA-256 without downloading its body.
- Verified downloads —
download(..., verify=True)verifies integrity against the server-reported digest. - 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-Digestheader (e.g., for directory listings or archive downloads),verify=Truedoes 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. Usedownload(..., 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
archiveis set,verify=Trueis automatically ignored because the server does not return aRepr-Digestheader 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:
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:
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¶
Delete by pattern¶
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