Metadata-Version: 2.4
Name: fileboy
Version: 0.3.0
Summary: Official Python client for Fileboy: upload files and videos, build image transform URLs, read video data.
Author: Fileboy
License: MIT
Project-URL: Homepage, https://fileboy.io/docs/libraries/python
Project-URL: Documentation, https://fileboy.io/docs/libraries/python
Project-URL: Changelog, https://github.com/CD2/Fileboy/blob/master/packages/python/CHANGELOG.md
Keywords: fileboy,file upload,image resize,video hosting,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: requests>=2.25; extra == "dev"
Dynamic: license-file

# Fileboy for Python

The official Python client for [Fileboy](https://fileboy.io): upload files and videos, build image
URLs that resize on the fly, and read video data. Its only dependency is `requests`.

## Install

```bash
pip install fileboy
```

Python 3.8 or newer.

## Quick start

```python
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:

```python
# 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",
)
```

```python
# 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.

```python
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.

```python
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.

```python
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"`.

```python
[
    # 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:

```python
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:

```python
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 `demo` key only accepts small images and cannot upload video.
* The client uses a `requests.Session`, so connections are reused between calls.

## Development

```bash
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:

```python
# 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](https://pypi.org) account that owns `fileboy` (the first upload claims the name) and an API token (account settings, scope: that project once it exists). Try [TestPyPI](https://test.pypi.org) first.

1. Bump the version in `src/fileboy/_version.py` and `pyproject.toml` (keep them equal) and add a section to `CHANGELOG.md`.
2. `pip install -e '.[dev]' && pytest` must pass.
3. `python -m pip install build twine && rm -rf dist && python -m build && twine check dist/*`
4. Rehearse: `twine upload --repository testpypi dist/*`.
5. Publish: `twine upload dist/*` with username `__token__` and the API token as the password.
6. 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.
