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
17 changes: 16 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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
## [6.3.0](https://github.com/uploadcare/pyuploadcare/compare/v6.2.1...v6.3.0) - 2026-08-04

### Added

Expand All @@ -22,6 +22,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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).
- Support for [file search](https://uploadcare.com/docs/file-search/):
- `Uploadcare.search_files()`, returning one page of results, and
`Uploadcare.iterate_search_files()`, which walks pages. As with the other list APIs, `limit`
is the total number of results to yield and `request_limit` is the page size.
- `FilesAPI.search()` for `POST /files/search/`.
- Typed requests: `FileSearchRequest` with `query`, `phrase`, `exact`, `datetime_uploaded`,
`size`, `is_image` and `tags` conditions plus the `fuzziness` and `sort` modifiers, built from
`SearchPhrase`, `SearchExact`, `DatetimeRange`, `SizeRange`, `TagsFilter` and `SearchSort`.
A plain dict in the same shape is accepted too. Requests are validated locally against the
documented API constraints before a request is made.
- `FileSearchResponse` with `next`, `previous`, `total`, `per_page` and `results`, where each
result is a `FileSearchInfo` — file info plus a `SearchHighlight`.
- `iterate_search_files()` warns when asked to page through a filter-only request without
`sort`, whose result order the API leaves undefined.
- A new `ucare search_files` command.

### Changed

Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Build file handling in minutes. Upload or accept user-generated content, store,
- [Usage](#usage)
- [Basic usage](#basic-usage)
- [File tags](#file-tags)
- [File search](#file-search)
- [Django integration](#django-integration)
- [Testing](#testing)
- [Demo app](#demo-app)
Expand Down Expand Up @@ -195,6 +196,64 @@ with open("sample-file.jpeg", "rb") as file_object:
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 `.`.

#### File search

A single [search](https://uploadcare.com/docs/file-search/) request can combine full-text search,
exact matching, range filters and tag filters. At least one condition is required:

```python
from pyuploadcare import FileSearchRequest, SizeRange, TagsFilter

response = uploadcare.search_files(
FileSearchRequest(
query="sunset",
tags=TagsFilter(all_=["cat"], none_=["draft"]),
size=SizeRange(gt=1024),
is_image=True,
fuzziness=True,
sort=["-score", "size"],
),
limit=50,
)

print(response.total, response.per_page)

for file_info in response.results:
print(file_info.original_filename, file_info.tags)
if file_info.highlight:
print(file_info.highlight.original_filename) # ['<em>sunset</em>.jpg']
```

Requests can also be plain dicts:

```python
response = uploadcare.search_files({"tags": {"all": ["cat"]}})
```

`search_files()` returns a single page: `limit` is the page size (1–100, defaults to 20) and
`offset + limit` must not exceed 1000.

To walk pages, use `iterate_search_files()`. There, following the SDK's other list APIs, `limit` is
the total number of results to yield and `request_limit` is the page size; the iterator stops on
its own once it reaches the 1000-result window:

```python
request = {"tags": {"all": ["cat"]}, "sort": ["-datetime_uploaded"]}

for file_info in uploadcare.iterate_search_files(request, limit=200):
print(file_info.uuid)
```

Always pass an explicit `sort` when paging through a filter-only request (one without `query` or
`phrase`): there is no relevance to rank by, so the order is undefined and paging can skip or
repeat files. The SDK emits a `UserWarning` if you don't.

Either way, search reaches the first 1000 results only. Narrow the query rather than paging
deeper.

> `highlight` values contain your users' filenames and metadata wrapped in `<em>` tags by the
> server. Treat them as untrusted text and escape them before rendering as HTML.

### Django integration

Let's add [File Uploader](https://uploadcare.com/docs/file-uploader/) to an existing Django project.
Expand Down
119 changes: 119 additions & 0 deletions docs/core_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,125 @@ Tags are available from the CLI as well::
.. _file tags documentation: https://uploadcare.com/docs/file-tags/


Searching files
---------------

A single search request (`file search documentation`_) can combine full-text search, exact
matching, range filters and tag filters. At least one condition is required: ``query``,
``phrase``, ``exact``, ``datetime_uploaded``, ``size``, ``is_image`` or ``tags``. ``fuzziness``
and ``sort`` are modifiers and do not count as conditions::

from pyuploadcare import (
DatetimeRange,
FileSearchRequest,
SearchExact,
SearchPhrase,
SizeRange,
TagsFilter,
)

response = uploadcare.search_files(
FileSearchRequest(
query='sunset',
tags=TagsFilter(all_=['cat'], none_=['draft']),
size=SizeRange(gt=1024, lte=10 * 1024 * 1024),
is_image=True,
fuzziness=True,
sort=['-score', 'size'],
),
limit=50,
)

print(response.total, response.per_page, response.next)

for file_info in response.results:
print(file_info.original_filename, file_info.tags)

``query`` searches across all searchable fields and must be at least 4 characters. ``phrase``
performs an ordered full-text match on a specific field, and ``exact`` matches values exactly::

request = FileSearchRequest(
phrase=SearchPhrase(original_filename='holiday photo'),
exact=SearchExact(detected_mime_type=['image/jpeg', 'image/png']),
datetime_uploaded=DatetimeRange(gte=datetime(2024, 1, 1)),
)

A field cannot appear in both ``phrase`` and ``exact`` in the same request.

``exact`` can also match metadata values. In the SDK this is a nested mapping, which is
serialized into the ``metadata[<key>]`` keys the API expects::

SearchExact(metadata={'album': ['holiday'], 'color': ['red']})

A request can equally be a plain dict, using the same shape::

response = uploadcare.search_files({
'exact': {'metadata': {'album': ['holiday']}},
'tags': {'all': ['cat']},
})

``sort`` accepts 1 to 4 keys out of ``score``, ``datetime_uploaded``, ``size`` and
``original_filename``, each optionally prefixed with ``-`` for descending order. Keys must be
unique and the same key must not be given in both directions.

Without ``sort``, results are ordered by relevance. A filter-only request has no ``query`` or
``phrase`` to rank by, so its order is undefined — always give such a request an explicit
``sort``.

Search indexing is asynchronous, so a file is not findable the instant it is uploaded — expect a
delay on the order of seconds. Do not search for a file you have just uploaded without retrying.

``search_files`` returns a single page. Its ``limit`` is the page size, 1 to 100, and the server
defaults to 20; ``offset + limit`` must not exceed 1000. Search cannot reach past the first 1000
results, so narrow the query instead of paging deeper.

To walk pages, use ``iterate_search_files``. Its ``limit`` means something different: as elsewhere
in the SDK, ``limit`` is the total number of results to yield and ``request_limit`` is the number
retrieved per request. The iterator applies the 1000-result window itself, clamping each page so
that no request exceeds it::

for file_info in uploadcare.iterate_search_files(
{'tags': {'all': ['cat']}, 'sort': ['-datetime_uploaded']}, limit=200
):
print(file_info.uuid)

.. warning::

Paging through a filter-only request without ``sort`` is unreliable: the order is undefined
between requests, so files may be skipped or repeated. ``iterate_search_files`` emits a
``UserWarning`` in that case.

Pass ``include_appdata=True`` to embed application data in every result::

response = uploadcare.search_files(request, include_appdata=True)
print(response.results[0].appdata)

Each result carries a ``highlight`` with the matched tokens wrapped in ``<em>`` tags. A field in it
is populated only when that field matched a full-text condition, so a filter-only search
highlights nothing — expect either no ``highlight`` at all or one whose every field is ``None``::

highlight = response.results[0].highlight
if highlight:
print(highlight.original_filename) # ['<em>sunset</em>-cat.jpg']
print(highlight.metadata) # {'album': 'summer <em>sunset</em>'}

.. warning::

``highlight`` values contain user-controlled content (filenames, metadata) plus markup added
by the server. They are not trusted HTML — escape them before rendering.

Search is available from the CLI as well. Because a descending sort key starts with a dash, pass
it with ``--sort=`` so it is not read as another option::

ucare search_files --query sunset --tags_any cat dog --is_image true \
--size_gt 1024 --sort=-score --limit 50

The CLI covers every condition except ``exact.metadata``, whose bracketed keys do not map onto a
command line option; use the Python API for that.

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


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

Expand Down
9 changes: 9 additions & 0 deletions pyuploadcare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@
from pyuploadcare.resources.file_list import FileList # noqa: F401
from pyuploadcare.resources.group_list import GroupList # noqa: F401
from pyuploadcare.api.entities import Webhook, ProjectInfo # noqa: F401
from pyuploadcare.api.search_entities import ( # noqa: F401
DatetimeRange,
FileSearchRequest,
SearchExact,
SearchPhrase,
SearchSort,
SizeRange,
TagsFilter,
)
from pyuploadcare.client import Uploadcare # noqa: F401
72 changes: 71 additions & 1 deletion pyuploadcare/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@

from .entities import UUIDEntity
from .metadata import validate_meta_key, validate_meta_value, validate_metadata
from .search_entities import FileSearchRequest
from .tags import validate_tags
from .utils import flatten_dict
from .utils import flatten_dict, require_optional_int, require_range


logger = logging.getLogger("pyuploadcare")


# File search pagination limits.
# https://uploadcare.com/docs/api/rest/file/search-files/
SEARCH_DEFAULT_LIMIT = 20 # the server default when `limit` is not sent
SEARCH_MAX_LIMIT = 100
SEARCH_MAX_WINDOW = 1000 # `offset` + `limit` must not exceed this


class FilesAPI(API, ListCountMixin, RetrieveMixin, DeleteWithResponseMixin):
resource_type = "files"
response_classes = {
Expand All @@ -55,8 +63,70 @@ class FilesAPI(API, ListCountMixin, RetrieveMixin, DeleteWithResponseMixin):
"batch_delete": responses.BatchFileOperationResponse,
"local_copy": responses.CreateLocalCopyResponse,
"remote_copy": responses.CreateRemoteCopyResponse,
"search": responses.FileSearchResponse,
}

def search(
self,
request: Union[FileSearchRequest, Dict[str, Any]],
limit: Optional[int] = None,
offset: Optional[int] = None,
include_appdata: bool = False,
) -> responses.FileSearchResponse:
"""Search files, returning a single page of results.

https://uploadcare.com/docs/file-search/

Args:
- request: a ``FileSearchRequest`` or a dict in the same shape.
A dict uses the SDK's shape for ``exact.metadata``, i.e.
``{"exact": {"metadata": {"color": ["red"]}}}``, not the wire
shape ``{"exact": {"metadata[color]": [...]}}``.
- limit: results per page, 1 to 100. The server defaults to 20.
- offset: how many results to skip. ``offset + limit`` must not
exceed 1000.
- include_appdata: embed application data in every result.
"""
search_request = (
request
if isinstance(request, FileSearchRequest)
else FileSearchRequest.model_validate(request)
)

require_optional_int("limit", limit)
require_optional_int("offset", offset)
require_range("limit", limit, minimum=1, maximum=SEARCH_MAX_LIMIT)
require_range("offset", offset, minimum=0)

effective_limit = SEARCH_DEFAULT_LIMIT if limit is None else limit
effective_offset = 0 if offset is None else offset

if effective_offset + effective_limit > SEARCH_MAX_WINDOW:
raise InvalidParamError(
"`offset` + `limit` must not exceed "
f"{SEARCH_MAX_WINDOW}, got "
f"{effective_offset} + {effective_limit}. "
"Narrow the query instead of paging deeper"
)

query_parameters: Dict[str, Any] = {}
if limit is not None:
query_parameters["limit"] = limit
if offset is not None:
query_parameters["offset"] = offset
if include_appdata:
query_parameters["include"] = "appdata"

url = self._build_url(
suffix="search", query_parameters=query_parameters
)
response_class = self._get_response_class("search")
json_response = self._client.post(
url, json=search_request.to_payload()
).json()
response = self._parse_response(json_response, response_class)
return cast(responses.FileSearchResponse, response)

def store(self, file_uuid: Union[UUID, str]) -> entities.FileInfo:
url = self._build_url(file_uuid, suffix="storage")
response_class = self._get_response_class("store")
Expand Down
27 changes: 27 additions & 0 deletions pyuploadcare/api/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ class FileInfo(UUIDEntity):
appdata: Optional[ApplicationDataSet] = None


class SearchHighlight(Entity):
"""Matched tokens wrapped in ``<em>`` tags by the search backend.

A field is populated only when it matched a full-text condition
(``query`` or ``phrase``). Without such a condition nothing is
highlighted: the API may then omit the object altogether, or send it
empty, in which case every field here is ``None``.

The values contain user-controlled content (filenames, metadata) plus
markup added by the server. They are **not** trusted HTML: escape the
surrounding text before rendering them.
"""

# OpenAPI: array of string
original_filename: Optional[List[str]] = None
# OpenAPI: array of string
detected_mime_type: Optional[List[str]] = None
# OpenAPI: object with additionalProperties of type string
metadata: Optional[Dict[str, str]] = None


class FileSearchInfo(FileInfo):
"""A file search result: file info plus the match highlight."""

highlight: Optional[SearchHighlight] = None


class GroupInfo(Entity):
id: str
_fetched: Optional[bool] = PrivateAttr(default=False)
Expand Down
6 changes: 6 additions & 0 deletions pyuploadcare/api/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
DocumentConvertInfo,
Entity,
FileInfo,
FileSearchInfo,
GroupInfo,
MetadataDict,
VideoConvertInfo,
Expand All @@ -33,6 +34,11 @@ class FileListResponse(PaginatedResponse):
results: List[FileInfo] # type: ignore


class FileSearchResponse(PaginatedResponse):
# https://uploadcare.com/docs/api/rest/file/search-files/
results: List[FileSearchInfo] # type: ignore


class GroupListResponse(PaginatedResponse):
# https://uploadcare.com/api-refs/rest-api/v0.5.0/#operation/groupsList
results: List[GroupInfo] # type: ignore
Expand Down
Loading