Fileboy for Python
The official Python client for Fileboy: upload files and videos, build image
URLs that resize on the fly, and read video data. Its only dependency is requests.
Install
pip install fileboy
Python 3.8 or newer.
Quick start
import os
import fileboy
client = fileboy.Client(public_key=os.environ["FILEBOY_PUBLIC_KEY"])
file = client.upload_file("report.pdf") # a path, a binary file object or bytes
print(file.id) # store this
client.build_file_url(file.id, download=True)
client.build_image_url(file.id, transforms=[{"type": "resize", "width": 300}, {"type": "format", "format": "webp"}])
# https://api.fileboy.io/images/get/<id>?transform=resize:300x_;format:webp
Django and other frameworks
Configure once, then call fileboy.client() anywhere:
# settings.py (or an AppConfig.ready())
import os
import fileboy
fileboy.configure(
public_key=os.environ["FILEBOY_PUBLIC_KEY"],
# private_key=..., only if your app uses signed requests
# api_url="https://api.fileboy.io",
)
# views.py
import fileboy
def upload(request):
file = fileboy.client().upload_file(request.FILES["file"], name=request.FILES["file"].name)
...
FILEBOY_PUBLIC_KEY, FILEBOY_PRIVATE_KEY and FILEBOY_API_URL are read from the environment when you do not set them.
What you can do
| Task | Method |
|---|---|
| Upload a path, file object or bytes | upload_file(file, name=, mime=, metadata=, folder_id=, folder_path=) |
| Upload from a path | upload_file_from_path(path, ...) |
| Upload from a URL | upload_file_from_url(url, name=, metadata=, expect=, folder_id=, folder_path=) |
| File details | fetch_file_data(id_or_label) |
| File URL | build_file_url(id_or_label, filename=, download=) |
| Image URL with transforms | build_image_url(id_or_label, transforms=, filename=, download=) |
| Download bytes | download_file(id_or_label, range=), download_image(id_or_label, transforms=, range=) |
| Upload a video | upload_video(file, name=, mime=, description=, tags=, metadata=) |
| Video details | fetch_video_data(id), fetch_video_info(id) |
| Manifest and poster URLs | build_video_manifest_url(id, type="hls"), build_video_poster_url(id) |
| Seek-preview thumbnails | build_video_thumbnails_url(id) (WebVTT), build_video_thumbnails_url(id, format="json") |
| Custom poster | upload_video_poster(video_id, image), download_video_poster(video_id) |
| Transcripts | upload_video_transcript(id, file, language="English"), fetch_video_transcript(id, transcript_id) |
| List, update and delete files (private key) | list_files(...), iter_files(...), update_file(id, ...), delete_file(id), delete_files(ids) |
| Folders (private key) | list_folders(...), iter_folders(...), get_folder(id), create_folder(name, parent_id=), create_folder(path=), update_folder(id, ...), delete_folder(id, contents=), move_files(ids, folder_id) |
| Manage videos, labels, usage (private key) | list_videos, update_video, delete_video, list_labels, create_label, delete_label, get_usage, get_storage_usage |
| A photo's EXIF data (private key) | get_file_exif(id_or_label, personal=True) returns a FileExif |
| Expiring links you can revoke (private key) | create_file_link(id, expires_in=), list_file_links(id), iter_file_links(id), revoke_file_link(id, link_id) |
| Signed access token | create_access_token(read=, write=, upload=, expires_in=) |
An id that is a UUID is used as is. Anything else is treated as a label in your app, and a value
that already contains a + is passed through. Labels are created in the dashboard.
Uploads return a FileData dataclass (id, name, original_filename, mime, metadata,
file_size, content_hash, signed, dies_at, created_at, folder_id, folder_path). upload_video returns a Video
(id, name, raw_file_id). range is a (first_byte, last_byte) tuple.
Managing files
Listing, updating and deleting files, videos and labels needs your app's private key. With only
the public key these methods raise ConfigurationError before any request is made.
client = fileboy.Client(public_key="PUBLIC", private_key="PRIVATE")
# One page, or every match across all pages
page = client.list_files(mime="image/", limit=50)
for file in page.items:
print(file.name, file.labels)
more = client.list_files(cursor=page.next_cursor) # next_cursor is None on the last page
for file in client.iter_files(metadata={"orderId": "42"}):
print(file.id)
# Metadata is merged (None removes a key). Pass replace_metadata=True to replace it all.
client.update_file(file_id, name="new.png", metadata={"keep": "1", "drop": None},
add_labels=["hero"], remove_labels=["old"])
client.update_file("hero", name="renamed.png") # an id without a hyphen is a label
client.delete_file(file_id) # NotFoundError if it is already gone
result = client.delete_files([id_a, id_b]) # up to 100; result.deleted, .not_found, .failed
client.list_videos(status="ready", tags=["launch"], metadata={"orderId": "42"})
client.update_video(video_id, name="launch.mp4")
# The description shows on the watch page. Tags and metadata are private, for your own records.
video = client.update_video(video_id, description="Launch day", add_tags=["launch"], remove_tags=["draft"],
metadata={"orderId": "42", "old": None})
video.description, video.tags, video.metadata # also in every video.* webhook
client.delete_video(video_id)
client.list_labels()
client.create_label("hero", file_id=file_id)
client.delete_label("hero")
# An image's colours: the dominant one, up to 16 main colours with their share of the image,
# vibrant and muted swatches, and a readable text colour (black or white) for each
palette = client.get_file_palette(file_id, colors=4)
palette["dominant"]["hex"], palette["colors"][0]["share"], palette["swatches"]["vibrant"]
usage = client.get_usage() # usage.files, usage.videos, usage.storage_bytes, usage.limits.files
# A photo's camera, lens, exposure, date taken and GPS position (EXIF, IPTC and XMP)
exif = client.get_file_exif(file_id)
exif.fields.model, exif.fields.aperture, exif.fields.shutter_speed, exif.fields.date_taken
exif.fields.gps # ExifGps(latitude=..., longitude=..., altitude=...) or None
exif.raw["exif"]["LensModel"] # every tag read, by source
client.get_file_exif(file_id, personal=False) # without GPS, place names, serial numbers or owner
# What the storage is made of, the biggest files and videos, and a daily total
storage = client.get_storage_usage(days=90, top=5)
storage.kinds["videoRenditions"].bytes # also images, files, videoOriginals, imageTransforms...
storage.largest_files[0].id, storage.largest_videos[0].storage_bytes
[(day.date, day.storage_bytes) for day in storage.history]
Errors keep the API's code and status on the exception, and api_message holds the API's own
message. NotFoundError, FileboyPermissionError/AuthenticationError and ValidationError tell
you what went wrong without reading the code.
Folders
Folders organise files. They are never part of a URL, so moving or renaming one changes no links. Every file starts at the root. These calls need the private key, like the rest of this section.
shoes = client.create_folder(path="/Products/Shoes") # creates what is missing, like mkdir -p
sale = client.create_folder("Sale", parent_id=shoes.id) # ValidationError (folder_exists) if it is taken
file = client.upload_file("shoe.png", folder_path="/Products/Shoes") # or folder_id=shoes.id
file.folder_id, file.folder_path
page = client.list_files(folder_id=shoes.id) # only the files directly in it
client.list_files(folder_path="/Products", recursive=True) # subfolders included
client.list_files(folder_id="root") # files in no folder
client.update_file(file.id, folder_path="/Archive") # one file; folder_id=None moves it to the root
client.move_files([id_a, id_b], shoes.id) # up to 100; result.moved, .not_found, .folder
client.move_files([id_a], None) # to the root
client.list_folders(parent_id="root") # top level, by name; also name=, sort=, order=, limit=, cursor=
client.list_folders(path="/Products/Shoes").items # [] or the one folder
client.get_folder(shoes.id).ancestors # the folders above, for breadcrumbs
client.update_folder(shoes.id, name="Boots")
client.update_folder(shoes.id, parent_id=None) # move it to the root
client.delete_folder(sale.id) # only an empty folder, otherwise ValidationError (folder_not_empty)
client.delete_folder(shoes.id, contents="moveToParent") # its files and folders move up a level
client.delete_folder(shoes.id, contents="delete") # deletes the files too, at most 1000
Uploads made with only the public key, or an access token, can use folders that exist but cannot create them.
Expiring links
An expiring link is a URL to one file that works until it expires or you revoke it. Hand it to a customer, an auditor or a partner, and take it back if you need to. Needs the private key.
from datetime import datetime, timezone
# A link that works for 7 days
link = client.create_file_link(file_id, expires_in=7 * 24 * 3600, note="for the auditors")
print(link.url, link.expires_at)
# A resized WebP of an image, downloaded as logo.webp, until a set time
client.create_file_link("logo", expires_at=datetime(2026, 12, 1, 9, tzinfo=timezone.utc),
transform="resize:400x_;format:webp", download=True, filename="logo.webp")
page = client.list_file_links(file_id, status="active") # newest first; status="all" is the default
for link in page.items:
print(link.id, link.status, link.use_count, link.last_used_at)
client.revoke_file_link(file_id, link.id) # stops working at once, safe to repeat
Give exactly one of expires_in (seconds) or expires_at (a datetime or ISO 8601 string), from
one minute to 365 days away. A file can have at most 100 active links (ValidationError,
too_many_links). Results are FileLink dataclasses (id, file_id, url, status,
expires_at, revoked_at, transform, download, filename, note, created_via,
created_at, last_used_at, use_count).
A link works until it expires or is revoked even if the file is "Signed URLs only" or the app
requires signed reads. It does not make a public file private: the file's own URL keeps working.
For a link per page view, sign_url is the better tool (no request, nothing stored); stored links
are for links you hand out and might take back.
Image transforms
transforms= takes a list of dicts applied in order. Pixel sizes are whole numbers; where a
percentage is allowed write {"pc": 50} (or "50pc"). Colours can be #rgb, #rrggbb,
#rrggbbaa, rgb(...), rgba(...) or a CSS name like "white".
[
# Size and shape
{"type": "resize", "width": 200, "height": 200, "fit": "cover"}, # fit: contain cover fill inside outside
{"type": "resize", "width": 400, "fit": "contain", "position": "top-left", "background": "#ffffff"},
{"type": "resize", "width": 300, "height": 300, "focus": {"x": {"pc": 30}, "y": {"pc": 70}}}, # keep this point in view
{"type": "resize", "width": 800, "upscale": False}, # never bigger than the original
{"type": "resizeAR", "ratio": [16, 9], "fit": "contain", "background": "black"},
{"type": "crop", "width": 400, "height": 300, "left": {"pc": 50}, "top": 120}, # centre of the area
{"type": "crop", "width": {"pc": 50}, "height": {"pc": 50}, "position": "top-left"},
{"type": "smartCrop", "width": 400, "height": 400}, # crops to the most detailed area
{"type": "trim", "threshold": 10, "color": "white"}, # cuts off a plain border; both optional
{"type": "rotate", "angle": 90}, # clockwise; other angles leave a background
{"type": "flip", "direction": "x"}, # x y xy
{"type": "radius", "radius": 24}, # or "max" for a circle
{"type": "pad", "padding": [10, 20], "background": "#fff"}, # all, [v, h] or [top, right, bottom, left]
{"type": "border", "width": 4, "color": "#000"},
{"type": "dpr", "ratio": 2}, # multiplies every pixel size
# Look
{"type": "blur", "amount": 5}, # 0 to 100
{"type": "sharpen"}, # or "amount": 0 to 10
{"type": "grayscale"},
{"type": "saturate", "amount": 1.5}, # 0 grey, 1 unchanged
{"type": "brightness", "amount": 10}, # -100 to 100
{"type": "contrast", "amount": 10}, # -100 to 100
{"type": "hue", "degrees": 90},
{"type": "gamma", "value": 1.2},
{"type": "sepia"}, # or "amount": 0 to 100
{"type": "tint", "color": "#ff0000"},
{"type": "opacity", "amount": 50},
{"type": "pixelate", "size": 12},
{"type": "invert"},
# Layers: another image file of the same app, or text, drawn on top
{"type": "overlay", "file": logo_id, "position": "bottom-right", "x": 20, "y": 20, "width": {"pc": 20}, "opacity": 80},
{"type": "overlay", "file": logo_id, "tile": True, "gap": 40, "rotate": -30, "opacity": 25}, # a watermark pattern
{"type": "overlay", "file": logo_id, "transforms": [{"type": "grayscale"}, {"type": "radius", "radius": "max"}]},
{"type": "text", "text": "© 2026 Fileboy", "font": "sans-bold", "size": {"pc": 4}, "color": "#fff",
"background": "#00000080", "padding": 8, "position": "bottom-left", "x": 16, "y": 16},
# Output
{"type": "format", "format": "auto"}, # auto (AVIF, then WebP, when the browser takes them) avif webp jpeg png tiff bmp
{"type": "format", "format": "json"}, # the image's details as JSON, not an image
{"type": "palette", "output": "css", "colors": 4}, # the main colours as JSON (default) or CSS, not an image
{"type": "quality", "value": 75},
{"type": "lossless"}, # lossless WebP or AVIF
{"type": "background", "color": "#fff"}, # default for transforms that leave space
{"type": "strip"}, # re-encode without metadata
]
Overlays and text take position (default center), x and y (moved in from that edge, pixels
or {"pc": n}, negative allowed), opacity (0 to 100), rotate, and tile with gap. An overlay
is the id of an image file in the same app; width and height size it (one keeps its shape, both
fit it inside the box) and transforms run on it first. Text is encoded for you
(transforms.encode_text), in the fonts sans, sans-bold, sans-italic, mono or mono-bold,
with size (pixels, or {"pc": n} of the image's height), color, background, padding, wrap
and align. If the overlay file is set to signed URLs only, sign the image URL: the signature
covers the overlay.
You can also pass a dict of name: value, such as {"resize": "300x_", "format": "webp", "grayscale": True}, or a ready-made string.
The builder checks what it can before you make a request; the image service answers anything it cannot apply with a 400 that says why.
Signed URLs
If your app requires signed reads, sign URLs on your server with the private key:
url = client.sign_url(client.build_image_url(file_id, transforms={"resize": (400, 400)}), expires_in=600)
query = client.sign_video(video_id, expires_in=4 * 3600) # add to any /videos/{id}/... URL
Signing makes no request. See https://fileboy.io/docs/delivery/signed-urls.
Share pages
A share page is a page anyone can open at https://fileboy.io/f/{id}, with a preview of the file
and a download button. It is off until you turn it on, and it follows the file's read access:
client.update_file(file_id, share_page=True)
link = client.build_share_page_url(file_id) # https://fileboy.io/f/2a86...
# A file set to Signed URLs only, or any file of an app that requires signed reads, only opens
# through a signed share link. Its preview and download stop working when the link does.
signed_link = client.sign_share_page_url(file_id, expires_in=7 * 24 * 3600)
update_file(file_id, share_page=False) turns the page off, and every link to it stops working.
ManagedFile.share_page says whether it is on. See https://fileboy.io/docs/delivery/share-pages.
Errors
Every error is a FileboyError with status, code (the API's error code) and body.
| Class | When |
|---|---|
AuthenticationError |
401 |
FileboyPermissionError |
403, including plan limits |
NotFoundError |
404 |
RateLimitError |
429 |
BadRequestError |
other 4xx, such as a file that is too large |
ValidationError |
the management API rejected your values (a BadRequestError) |
ServerError |
5xx |
FileboyConnectionError |
timeouts and network failures |
InvalidArgumentError, ConfigurationError |
bad arguments, before any request |
FileboyPermissionError and FileboyConnectionError are prefixed so they do not shadow the
Python built-ins PermissionError and ConnectionError.
Notes
- File, image and video reads need no authorization, so URL builders never sign anything.
- The shared
demokey only accepts small images and cannot upload video. - The client uses a
requests.Session, so connections are reused between calls.
Development
pip install -e ".[dev]"
pytest
The tests run against a local fake server. The transform builder is also checked against
packages/sdk-fixtures/transforms.json, the same cases the Go image parser is tested with.
MIT licensed.
Webhooks
Verify the requests Fileboy sends to your endpoints. Use the raw body, exactly as received:
# Django
import fileboy
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def fileboy_webhook(request):
try:
event = fileboy.verify_webhook(request.body, request.headers, settings.FILEBOY_WEBHOOK_SECRET)
except fileboy.WebhookVerificationError as error:
return HttpResponseBadRequest(error.code) # "invalid_signature", "timestamp_out_of_tolerance", ...
if event["type"] == "video.ready":
... # event["resource"]["urls"]["hls"], event["resource"]["duration"]
return HttpResponse(status=200)
verify_webhook returns the parsed event (a dict) or raises WebhookVerificationError. The timestamp
must be within 5 minutes (tolerance_seconds= changes that). Events carry a unique id that is the same
on every retry, so use it to ignore duplicates. fileboy.sign_webhook(body, timestamp, secret) builds a
signature for your own tests.
Publishing (maintainers)
You need a PyPI account that owns fileboy (the first upload claims the name) and an API token (account settings, scope: that project once it exists). Try TestPyPI first.
- Bump the version in
src/fileboy/_version.pyandpyproject.toml(keep them equal) and add a section toCHANGELOG.md. pip install -e '.[dev]' && pytestmust pass.python -m pip install build twine && rm -rf dist && python -m build && twine check dist/*- Rehearse:
twine upload --repository testpypi dist/*. - Publish:
twine upload dist/*with username__token__and the API token as the password. - Tag the commit:
git tag python-vX.Y.Z && git push origin python-vX.Y.Z.
PyPI never lets you reuse a version number, so bump it for every fix.
Metadata
Release files for fileboy 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| fileboy-0.3.0.tar.gz | 59.4 kB | Without provenance | Details for fileboy-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| fileboy-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for fileboy-0.3.0-py3-none-any.whl |
Total release size: 102.6 kB
Release files / fileboy-0.3.0.tar.gz
| Download URL | fileboy-0.3.0.tar.gz |
|---|---|
| Size | 59.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1536da9b041e4e1421bbefa12570d79bc0c27aae1bff05445d84cc229170a268
|
|
BLAKE2b-256 checksum How to use checksums |
d5e612fe093cbbdf0048a95f54461feb627136c2b560bf02e0da1357f478f1b4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / fileboy-0.3.0-py3-none-any.whl
| Download URL | fileboy-0.3.0-py3-none-any.whl |
|---|---|
| Size | 43.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d0859a00d0ec94e23ac1575961bba9d2c3c6fcfb3181e59abb983479ffd23e66
|
|
BLAKE2b-256 checksum How to use checksums |
3498e6998b8c907309e81c264f8773141c0c7403746d2d4b7d9bc011322da90c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Security for fileboy 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.