Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.3.0](https://github.com/uploadcare/pyuploadcare/compare/v6.2.1...v6.3.0) - 2026-08-03

### Added

- Support for [file tags](https://uploadcare.com/docs/file-tags/):
- `TagsAPI` (`uploadcare.tags_api`) with `get()`, `replace()` and `update()` methods, covering
`GET`, `PUT` and `PATCH` on `/files/{uuid}/tags/`.
- For `File`: a `tags` property plus `get_tags()`, `set_tags()` and `update_tags()` methods.
- A `tags` argument for `Uploadcare.upload()`, `Uploadcare.upload_files()` and
`Uploadcare.multipart_upload()`. Uploads from url do not support tags and raise
`InvalidParamError` instead of silently dropping them.
- `tags` in `FileInfo`.
- New `ucare` commands `get_file_tags`, `set_file_tags` and `update_file_tags`, and a `--tags`
option for `ucare upload`.
- `TagValidationError`, raised when tags exceed the API limits (50 tags per file, 100 characters
per tag, Latin letters, digits, `-`, `_` and `.` only).

### Changed

- `FileInfo.model_dump()`, and therefore `File.info`, now always contains a `tags` key. It is
`None` for responses that do not report tags, such as upload responses, and `[]` for files
without tags.

## [6.2.1](https://github.com/uploadcare/pyuploadcare/compare/v6.2.0...v6.2.1) - 2025-09-02

### Added
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Build file handling in minutes. Upload or accept user-generated content, store,
- [Requirements](#requirements)
- [Usage](#usage)
- [Basic usage](#basic-usage)
- [File tags](#file-tags)
- [Django integration](#django-integration)
- [Testing](#testing)
- [Demo app](#demo-app)
Expand Down Expand Up @@ -163,6 +164,37 @@ print(ucare_file.cdn_url) # https://demo.ucarecd.net/640fe4b7-7352-42ca-8d87-0e

There’s a lot more to uncover. For more information please refer to the [documentation](#documentation).

#### File tags

Files can carry a list of [tags](https://uploadcare.com/docs/file-tags/) you can later filter on:

```python
file_ = uploadcare.file("640fe4b7-7352-42ca-8d87-0e4387957157")

file_.get_tags() # ['cat', 'animal']
file_.set_tags(["cat", "animal", "cute"]) # replace all tags
file_.update_tags(add=["pet"], delete=["animal"]) # add and delete atomically
file_.set_tags([]) # clear all tags
```

`set_tags()` and `update_tags()` return the resulting tag list along with what changed:

```python
response = file_.update_tags(add=["pet"], delete=["animal"])
print(response.tags, response.added, response.deleted)
# ['cat', 'cute', 'pet'] ['pet'] ['animal']
```

Tags can also be attached at upload time:

```python
with open("sample-file.jpeg", "rb") as file_object:
ucare_file = uploadcare.upload(file_object, tags=["cat", "cute"])
```

Tags are lowercased, trimmed and deduplicated. A file can hold up to 50 tags of up to 100
characters each, made of Latin letters, digits, `-`, `_` and `.`.

### Django integration

Let's add [File Uploader](https://uploadcare.com/docs/file-uploader/) to an existing Django project.
Expand Down
69 changes: 69 additions & 0 deletions docs/core_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,75 @@ But you should better use a special attribute of `File.info`::
file_metadata = file.info["metadata"]


File tags
---------

Each file can carry a list of tags (`file tags documentation`_), which you can later use as a
filter when searching files.

Tags may be initially set while uploading::

with open('file.txt', 'rb') as file_object:
ucare_file: File = uploadcare.upload(file_object, tags=['cat', 'cute'])

While uploading multiple files at once, the tags are applied to every file in the collection::

file1 = open('file1.txt', 'rb')
file2 = open('file2.txt', 'rb')
ucare_files: List[File] = uploadcare.upload_files([file1, file2], tags=['cat'])
# don't forget to close the files, of course

Uploads from URL do not support tags. Use ``File.set_tags()`` after such an upload instead.

Read the current tags of a file::

file = uploadcare.file('740e1b8c-1ad8-4324-b7ec-112c79d8eac2')
tags: List[str] = file.get_tags()

Or, if the file info is already loaded, without an extra request::

tags = file.tags

``File.tags`` reads from the cached file info, fetching it first if nothing is cached yet. It is
``[]`` when the file has no tags, and ``None`` only when the cached info came from a response that
does not report tags at all. In practice that happens after a multipart upload, whose response is
cached as the file info and carries no ``tags`` — call ``File.update_info()`` or
``File.get_tags()`` there. A direct upload caches nothing, so reading ``File.tags`` after it
fetches the info and returns the stored tags.

Replace all tags of a file. Passing an empty list clears them::

response = file.set_tags(['cat', 'animal', 'cute'])
print(response.tags, response.added, response.deleted)

file.set_tags([])

Add and/or delete tags atomically::

response = file.update_tags(add=['pet'], delete=['animal'])

Both methods return a response carrying the resulting ``tags`` plus the ``added`` and ``deleted``
tags. The same operations are available on the API directly::

uploadcare.tags_api.get(file_id)
uploadcare.tags_api.replace(file_id, ['cat', 'animal'])
uploadcare.tags_api.update(file_id, add=['pet'], delete=['animal'])

Tags are normalized before being sent: they are lowercased, trimmed, deduplicated keeping the
first occurrence, and empty ones are dropped. A file can hold up to 50 tags of up to 100
characters each, containing Latin letters, digits, ``-``, ``_`` and ``.`` only. Anything else
raises ``TagValidationError`` before a request is made.

Tags are available from the CLI as well::

ucare get_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2
ucare set_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 cat animal
ucare update_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 --add pet --delete animal
ucare upload file.txt --tags cat cute

.. _file tags documentation: https://uploadcare.com/docs/file-tags/


Video conversion
----------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pyuploadcare"
version = "6.2.1"
version = "6.3.0"
description = "Python library for Uploadcare.com"
authors = ["Uploadcare Inc <hello@uploadcare.com>"]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion pyuploadcare/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# isort: skip_file
__version__ = "6.2.1"
__version__ = "6.3.0"

from pyuploadcare.resources.file import File # noqa: F401
from pyuploadcare.resources.file_group import FileGroup # noqa: F401
Expand Down
1 change: 1 addition & 0 deletions pyuploadcare/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
GroupsAPI,
MetadataAPI,
ProjectAPI,
TagsAPI,
UploadAPI,
VideoConvertAPI,
WebhooksAPI,
Expand Down
102 changes: 102 additions & 0 deletions pyuploadcare/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
from pyuploadcare.exceptions import (
APIError,
DuplicateFileError,
InvalidParamError,
InvalidRequestError,
WebhookIsNotUnique,
)

from .entities import UUIDEntity
from .metadata import validate_meta_key, validate_meta_value, validate_metadata
from .tags import validate_tags
from .utils import flatten_dict


Expand Down Expand Up @@ -271,6 +273,20 @@ def generate_secure_signature(secret: str, expire: int):
secret.encode("utf-8"), str(expire).encode("utf-8"), hashlib.sha256
).hexdigest()

@staticmethod
def _set_tags(data: Dict[str, Any], tags: Optional[Iterable[str]]) -> None:
"""Add the comma-separated `tags` form field to `data`, if any.

The field is omitted entirely when there is nothing to send.
"""
if tags is None:
return

validated_tags = validate_tags(tags)

if validated_tags:
data["tags"] = ",".join(validated_tags)

def upload( # noqa: C901
self,
files: RequestFiles,
Expand All @@ -280,6 +296,7 @@ def upload( # noqa: C901
secret_key: Optional[str] = None,
store: Optional[str] = "auto",
expire: Optional[int] = None,
tags: Optional[Iterable[str]] = None,
) -> Dict[str, Any]:
data = {}

Expand All @@ -292,6 +309,8 @@ def upload( # noqa: C901
validate_metadata(common_metadata)
data.update(flatten_dict(common_metadata))

self._set_tags(data, tags)

data["UPLOADCARE_PUB_KEY"] = public_key

if secure_upload:
Expand All @@ -318,6 +337,7 @@ def start_multipart_upload(
store: Optional[str] = None,
secure_upload: bool = False,
expire: Optional[int] = None,
tags: Optional[Iterable[str]] = None,
):
data = {
"filename": file_name,
Expand All @@ -333,6 +353,8 @@ def start_multipart_upload(
validate_metadata(metadata)
data.update(flatten_dict(metadata))

self._set_tags(data, tags)

if secure_upload:
expire = (
(int(time()) + self.signed_uploads_ttl)
Expand Down Expand Up @@ -515,6 +537,86 @@ def get_key(self, file_uuid: Union[UUID, str], mkey: str) -> str:
return cast(str, response)


class TagsAPI(API):
"""File tags.

https://uploadcare.com/docs/file-tags/
"""

resource_type = "files"
response_classes = {
"get": responses.GetFileTagsResponse,
"set": responses.UpdateFileTagsResponse,
"update": responses.UpdateFileTagsResponse,
}

@staticmethod
def _canonical_uuid(file_uuid: Union[UUID, str]) -> str:
"""Return a canonical UUID string, rejecting anything else.

``API._build_url`` joins the identifier with ``urljoin``, so a value
like ``"//example.com/x"`` or an absolute URL would replace the
configured API origin on an authenticated request.
"""
try:
return str(UUID(str(file_uuid)))
except (AttributeError, TypeError, ValueError):
raise InvalidParamError(f"Invalid UUID: {file_uuid!s}")
Comment thread
dmitry-mukhin marked this conversation as resolved.

def _tags_url(self, file_uuid: Union[UUID, str]) -> str:
return self._build_url(self._canonical_uuid(file_uuid), suffix="tags")

def get(self, file_uuid: Union[UUID, str]) -> List[str]:
"""Return the tags of a file, an empty list if it has none."""
url = self._tags_url(file_uuid)
response_class = self._get_response_class("get")
json_response = self._client.get(url).json()
response = self._parse_response(json_response, response_class)
return cast(responses.GetFileTagsResponse, response).tags

def set(
self, file_uuid: Union[UUID, str], tags: Iterable[str]
) -> responses.UpdateFileTagsResponse:
"""Replace all tags of a file.

Passing an empty collection clears the tags.
"""
url = self._tags_url(file_uuid)
data = {"tags": validate_tags(tags)}
response_class = self._get_response_class("set")
json_response = self._client.put(url, json=data).json()
response = self._parse_response(json_response, response_class)
return cast(responses.UpdateFileTagsResponse, response)

def update(
self,
file_uuid: Union[UUID, str],
add: Optional[Iterable[str]] = None,
delete: Optional[Iterable[str]] = None,
) -> responses.UpdateFileTagsResponse:
"""Add and/or delete tags of a file atomically.

Both arguments are optional, matching the endpoint: calling this
without them sends an empty request and returns the current state.
"""
data: Dict[str, List[str]] = {}

if add is not None:
data["add"] = validate_tags(add)

if delete is not None:
# No count limit here: `delete` is a list of candidates and tags
# that are not present are ignored, so it may legitimately be
# longer than the per-file storage limit.
data["delete"] = validate_tags(delete, max_count=None)

url = self._tags_url(file_uuid)
response_class = self._get_response_class("update")
json_response = self._client.patch(url, json=data).json()
response = self._parse_response(json_response, response_class)
return cast(responses.UpdateFileTagsResponse, response)


class AddonsAPI(API):
resource_type = "addons"
request_type: Type[AddonExecutionGeneralRequestData] = (
Expand Down
3 changes: 3 additions & 0 deletions pyuploadcare/api/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ class FileInfo(UUIDEntity):
datetime_stored: Optional[datetime] = None
datetime_uploaded: Optional[datetime] = None
metadata: Optional[MetadataDict] = None
# `[]` when the file has no tags. `None` when the endpoint does not report
# tags at all, e.g. upload responses.
tags: Optional[List[str]] = None
is_image: Optional[bool] = None
is_ready: Optional[bool] = None
mime_type: Optional[str] = None
Expand Down
13 changes: 13 additions & 0 deletions pyuploadcare/api/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ class GetAllMetadataResponse(RootModel, Entity):
root: MetadataDict


class GetFileTagsResponse(Response):
# https://uploadcare.com/docs/api/rest/file-tags/get-tags/
tags: List[str]


class UpdateFileTagsResponse(Response):
# https://uploadcare.com/docs/api/rest/file-tags/put-tags/
# https://uploadcare.com/docs/api/rest/file-tags/patch-tags/
tags: List[str]
added: List[str]
deleted: List[str]


class AddonResponseResult(Entity):
pass

Expand Down
Loading