Loom

Python Client Usage

The Python client is a synchronous binding for the Loom REST API. It is built entirely on the Python standard library and has no third-party dependencies, so it drops into a processing script or a container without pulling anything else in.

It requires Python 3.10 or newer.

Installing

The client lives in the clients/python/ directory of the source repository and is installed from there:

pip install ./clients/python

It is not yet published to the Python Package Index.

Connecting

from loom_client import LoomClient

with LoomClient(host="localhost", port=8092) as client:
    client.authenticate("admin", "secret")
    print(client.rest_info().body().version)

Using the client as a context manager closes the connection when the block ends. If you would rather manage it yourself, call close().

You can also read the connection settings from the environment — LOOM_HOST, LOOM_PORT, LOOM_SCHEME, LOOM_TIMEOUT, LOOM_TOKEN, and LOOM_USER / LOOM_PASSWORD:

client = LoomClient.from_env()

Authenticating

authenticate() logs in and keeps the token for every later call:

client.authenticate("admin", "secret")

If you already hold a long-lived API token, pass it in directly instead:

client = LoomClient(host="localhost", port=8092, token=api_token)

Sending requests

Every method returns a request that has not been sent yet. That is deliberate: it leaves room to attach paging and filtering before the call goes out.

users = client.list_users().limit(50).sort("username").body()

There are two ways to finish a request. body() gives you the parsed result, which is what you usually want. execute() gives you the status code and response headers as well:

user = client.load_user(user_uuid).body()

response = client.load_user(user_uuid).execute()
print(response.status, response.body.username)

Working with assets

An asset can be addressed either by its identifier or by the SHA-512 of its content. Every asset method accepts both, which means a script that has just hashed a file can look the asset up without knowing its identifier first:

asset = client.load_asset("3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d").body()
same = client.load_asset("cf83e1357eefb8bd...").body()

Creating, updating and deleting:

from loom_client.models import AssetCreateRequest, AssetUpdateRequest, FileInfo, HashInfo

created = client.create_asset(
    AssetCreateRequest(
        hashes=HashInfo(sha512=digest),
        file=FileInfo(
            filename="photo.jpg",
            mime_type="image/jpeg",
            size=size_in_bytes,
            origin="import-script",
        ),
    )
).body()

client.update_asset(created.uuid, AssetUpdateRequest(filename="beach.jpg")).execute()
client.delete_asset(created.uuid).execute()

An asset needs an origin — the request is rejected without one.

Uploading and downloading

asset = client.upload_asset("/path/to/photo.jpg", library_uuid=library_uuid).body()

with client.download_asset_binary(asset.uuid).body() as binary:
    binary.save("/tmp/photo.jpg")

Downloads are streamed, so use the result as a context manager or close it when you are done. Uploads are held in memory while they are sent; the limit is 64 MiB by default.

Paging through results

Listings are paged. iter() walks every page for you:

for asset in client.list_assets().iter(page_size=100):
    print(asset.uuid)

If you would rather page yourself, each result carries the total count and a cursor:

page = client.list_assets().limit(100).body()
print(page.metainfo.total_count)

next_page = client.list_assets().limit(100).from_(page.metainfo.last_uuid).body()

Filtering and searching

Filters are built with small helpers and can be combined; results must match all of them:

from loom_client import eq, gte

client.list_users().filter(eq("username", "joedoe")).body()
client.list_assets().filter(gte("size", "1MB")).body()

Full-text search is separate, and takes its own options:

results = client.search("aurora", types="asset,transcript", limit=50).body()

for hit in results:
    print(f"{hit.score:.2f}  {hit.title}")

Tags, tasks and comments

from loom_client.models import TagCreateRequest

client.tag_asset(asset_uuid, TagCreateRequest(name="holiday", collection="Travel")).body()
client.untag_asset(asset_uuid, tag_uuid).execute()

for task in client.list_asset_tasks(asset_uuid).body():
    print(task.title)

A tag always belongs to a collection.

Handling errors

Anything other than a successful response raises an error chosen by what went wrong, so you can catch exactly the case you care about:

from loom_client import LoomConnectionError, LoomForbiddenError, LoomNotFoundError

try:
    asset = client.load_asset(asset_uuid).body()
except LoomNotFoundError:
    print("no such asset")
except LoomForbiddenError as e:
    print(f"not allowed: {e.message}")
except LoomConnectionError:
    print("the server could not be reached")

LoomError is the base of all of them, so catching it catches everything the client can raise. Every error carries the server’s own explanation in message and the status code in status.

Full replacement

Most updates change only the fields you set. Full replacement is different: it requires every replaceable property to be present, and the request is rejected if any are missing. Load the element first, change what you need, and send the whole thing back:

user = client.load_user(user_uuid).body()
user.email = "new@example.com"
client.replace_user(user_uuid, user).execute()

Things to be aware of

  • Content addressing works for an asset itself, but not for anything nested under it. Tags, tasks, detections and reactions all need the asset’s identifier; passing a hash raises an error rather than sending a request that would fail.

  • Most timestamps are ISO-8601 strings, but a few from search are numbers. Passing either through parse_instant() gives you a datetime.

  • Fields the client does not recognise are preserved rather than dropped, so reading an element, changing one field and saving it back is safe against a newer server.

Looking for something else?