diff --git a/HISTORY.md b/HISTORY.md index e972aeb6..0a35b55f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,44 @@ 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-04 + +### 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). +- 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 + +- `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 diff --git a/README.md b/README.md index 6f841158..ea84fcff 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Build file handling in minutes. Upload or accept user-generated content, store, - [Requirements](#requirements) - [Usage](#usage) - [Basic usage](#basic-usage) + - [File tags](#file-tags) + - [File search](#file-search) - [Django integration](#django-integration) - [Testing](#testing) - [Demo app](#demo-app) @@ -163,6 +165,95 @@ 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 `.`. + +#### 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) # ['sunset.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 `` 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. diff --git a/docs/core_api.rst b/docs/core_api.rst index 0ff62b91..ae2f68cf 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -199,6 +199,194 @@ 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/ + + +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[]`` 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 ```` 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) # ['sunset-cat.jpg'] + print(highlight.metadata) # {'album': 'summer sunset'} + +.. 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 ---------------- diff --git a/pyproject.toml b/pyproject.toml index 2931deec..04727956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] readme = "README.md" diff --git a/pyuploadcare/__init__.py b/pyuploadcare/__init__.py index 8a882986..a44f6d3a 100644 --- a/pyuploadcare/__init__.py +++ b/pyuploadcare/__init__.py @@ -1,9 +1,18 @@ # 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 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 diff --git a/pyuploadcare/api/__init__.py b/pyuploadcare/api/__init__.py index f949b277..4b0fcd4f 100644 --- a/pyuploadcare/api/__init__.py +++ b/pyuploadcare/api/__init__.py @@ -5,6 +5,7 @@ GroupsAPI, MetadataAPI, ProjectAPI, + TagsAPI, UploadAPI, VideoConvertAPI, WebhooksAPI, diff --git a/pyuploadcare/api/api.py b/pyuploadcare/api/api.py index 6a60b9a0..4258f0c5 100644 --- a/pyuploadcare/api/api.py +++ b/pyuploadcare/api/api.py @@ -28,18 +28,28 @@ 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 .utils import flatten_dict +from .search_entities import FileSearchRequest +from .tags import validate_tags +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 = { @@ -53,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") @@ -271,6 +343,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, @@ -280,6 +366,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 = {} @@ -292,6 +379,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: @@ -318,6 +407,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, @@ -333,6 +423,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) @@ -515,6 +607,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, + "replace": 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}") + + 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 replace( + 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("replace") + 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] = ( diff --git a/pyuploadcare/api/entities.py b/pyuploadcare/api/entities.py index 832595a3..60799b2f 100644 --- a/pyuploadcare/api/entities.py +++ b/pyuploadcare/api/entities.py @@ -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 @@ -237,6 +240,33 @@ class FileInfo(UUIDEntity): appdata: Optional[ApplicationDataSet] = None +class SearchHighlight(Entity): + """Matched tokens wrapped in ```` 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) diff --git a/pyuploadcare/api/responses.py b/pyuploadcare/api/responses.py index 0bc1b540..3f4aff11 100644 --- a/pyuploadcare/api/responses.py +++ b/pyuploadcare/api/responses.py @@ -8,6 +8,7 @@ DocumentConvertInfo, Entity, FileInfo, + FileSearchInfo, GroupInfo, MetadataDict, VideoConvertInfo, @@ -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 @@ -79,6 +85,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 diff --git a/pyuploadcare/api/search_entities.py b/pyuploadcare/api/search_entities.py new file mode 100644 index 00000000..40a30915 --- /dev/null +++ b/pyuploadcare/api/search_entities.py @@ -0,0 +1,292 @@ +"""Request models for file search. + +https://uploadcare.com/docs/file-search/ +https://uploadcare.com/docs/api/rest/file/search-files/ +""" + +from datetime import datetime +from enum import Enum +from typing import Any, ClassVar, Dict, List, Optional, Tuple + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictInt, + model_validator, +) + +from .metadata import validate_meta_key +from .tags import validate_tags + + +# The shortest accepted full-text term, per the API reference. +MIN_TERM_LENGTH = 4 + +# `sort` accepts 1-4 unique keys. +MIN_SORT_KEYS = 1 +MAX_SORT_KEYS = 4 + + +class SearchSort(str, Enum): + """Sort keys accepted by file search. `-` prefix means descending.""" + + SCORE = "score" + SCORE_DESC = "-score" + DATETIME_UPLOADED = "datetime_uploaded" + DATETIME_UPLOADED_DESC = "-datetime_uploaded" + SIZE = "size" + SIZE_DESC = "-size" + ORIGINAL_FILENAME = "original_filename" + ORIGINAL_FILENAME_DESC = "-original_filename" + + +class SearchRequestModel(BaseModel): + """Base for search request models. + + ``extra="forbid"`` so a misspelled key is reported instead of silently + dropped, which matters most when a request is built from a plain dict. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + def _set_field_names(self) -> List[str]: + """Names of the fields that carry an actual condition.""" + return [ + name for name, value in self.__dict__.items() if value is not None + ] + + +class DatetimeRange(SearchRequestModel): + gt: Optional[datetime] = None + gte: Optional[datetime] = None + lt: Optional[datetime] = None + lte: Optional[datetime] = None + + @model_validator(mode="after") + def _at_least_one_bound(self) -> "DatetimeRange": + if not self._set_field_names(): + raise ValueError( + "at least one of `gt`, `gte`, `lt` or `lte` is required" + ) + return self + + +class SizeRange(SearchRequestModel): + # Strict, so `gt=True` is rejected instead of silently becoming `gt=1`. + gt: Optional[StrictInt] = Field(None, ge=0) + gte: Optional[StrictInt] = Field(None, ge=0) + lt: Optional[StrictInt] = Field(None, ge=0) + lte: Optional[StrictInt] = Field(None, ge=0) + + @model_validator(mode="after") + def _at_least_one_bound(self) -> "SizeRange": + if not self._set_field_names(): + raise ValueError( + "at least one of `gt`, `gte`, `lt` or `lte` is required" + ) + return self + + +class SearchPhrase(SearchRequestModel): + """Ordered full-text match. Each value must be at least 4 characters.""" + + original_filename: Optional[str] = Field(None, min_length=MIN_TERM_LENGTH) + metadata: Optional[str] = Field(None, min_length=MIN_TERM_LENGTH) + detected_mime_type: Optional[str] = Field(None, min_length=MIN_TERM_LENGTH) + + @model_validator(mode="after") + def _at_least_one_field(self) -> "SearchPhrase": + if not self._set_field_names(): + raise ValueError("`phrase` requires at least one field") + return self + + +class SearchExact(SearchRequestModel): + """Exact matching. Each key takes a non-empty array of values.""" + + uuid: Optional[List[str]] = Field(None, min_length=1) + detected_mime_type: Optional[List[str]] = Field(None, min_length=1) + original_filename: Optional[List[str]] = Field(None, min_length=1) + # Serialized as `metadata[]` by `FileSearchRequest.to_payload()`. + metadata: Optional[Dict[str, List[str]]] = None + + @model_validator(mode="after") + def _validate_metadata(self) -> "SearchExact": + if self.metadata is None: + return self + + for key, values in self.metadata.items(): + # Keeps a `[` or `]` in a key from forging a different wire key. + validate_meta_key(key) + + if not values: + raise ValueError( + f"`exact.metadata[{key}]` requires a non-empty array" + ) + + return self + + @model_validator(mode="after") + def _at_least_one_condition(self) -> "SearchExact": + # An empty `metadata` mapping carries no condition, so it must not + # count towards this check: it would serialize to `"exact": {}`. + has_condition = ( + self.uuid is not None + or self.detected_mime_type is not None + or self.original_filename is not None + or bool(self.metadata) + ) + + if not has_condition: + raise ValueError("`exact` requires at least one condition") + + return self + + +class TagsFilter(SearchRequestModel): + """Tag filters. + + ``any``, ``all`` and ``none`` shadow Python builtins and keywords, so the + attributes are suffixed with an underscore and aliased to the wire names. + Both ``TagsFilter(any_=[...])`` and ``TagsFilter(**{"any": [...]})`` work. + """ + + any_: Optional[List[str]] = Field(None, alias="any") + all_: Optional[List[str]] = Field(None, alias="all") + none_: Optional[List[str]] = Field(None, alias="none") + + @model_validator(mode="after") + def _normalize_and_check(self) -> "TagsFilter": + for name in ("any_", "all_", "none_"): + value = getattr(self, name) + if value is not None: + # Stored tags are normalized, so filters have to be too. + # No count limit: the 50-tag ceiling applies to the tags + # stored on a single file, not to a filter's alternatives. + object.__setattr__( + self, name, validate_tags(value, max_count=None) + ) + + if not any(getattr(self, name) for name in ("any_", "all_", "none_")): + raise ValueError( + "`tags` requires at least one non-empty list of tags" + ) + + return self + + +class FileSearchRequest(SearchRequestModel): + """A file search request. + + 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. + """ + + query: Optional[str] = Field(None, min_length=MIN_TERM_LENGTH) + phrase: Optional[SearchPhrase] = None + exact: Optional[SearchExact] = None + datetime_uploaded: Optional[DatetimeRange] = None + size: Optional[SizeRange] = None + # Strict, so `1` or `"true"` is rejected rather than silently coerced. + is_image: Optional[StrictBool] = None + tags: Optional[TagsFilter] = None + fuzziness: Optional[StrictBool] = None + sort: Optional[List[SearchSort]] = Field( + None, min_length=MIN_SORT_KEYS, max_length=MAX_SORT_KEYS + ) + + CONDITION_FIELDS: ClassVar[Tuple[str, ...]] = ( + "query", + "phrase", + "exact", + "datetime_uploaded", + "size", + "is_image", + "tags", + ) + + # Fields `phrase` and `exact` have in common. `metadata` is excluded on + # purpose: `phrase.metadata` and `exact.metadata[]` are different + # field names on the wire, so they do not collide. + OVERLAPPING_FIELDS: ClassVar[Tuple[str, ...]] = ( + "original_filename", + "detected_mime_type", + ) + + @model_validator(mode="after") + def _at_least_one_condition(self) -> "FileSearchRequest": + if all(getattr(self, name) is None for name in self.CONDITION_FIELDS): + raise ValueError( + "at least one of " + + ", ".join(f"`{name}`" for name in self.CONDITION_FIELDS) + + " is required" + ) + return self + + @model_validator(mode="after") + def _no_phrase_and_exact_overlap(self) -> "FileSearchRequest": + if self.phrase is None or self.exact is None: + return self + + for name in self.OVERLAPPING_FIELDS: + if ( + getattr(self.phrase, name) is not None + and getattr(self.exact, name) is not None + ): + raise ValueError( + f"`{name}` cannot appear in both `phrase` and `exact`" + ) + + return self + + @model_validator(mode="after") + def _unique_sort_keys(self) -> "FileSearchRequest": + if self.sort is None: + return self + + seen = set() + + for key in self.sort: + # Both directions of one key count as the same key. + field = key.value.lstrip("-") + + if field in seen: + raise ValueError( + f"`sort` must not contain `{field}` more than once, " + "in either direction" + ) + + seen.add(field) + + return self + + def has_undefined_order(self) -> bool: + """Whether the result order of this request is undefined. + + Without ``sort``, results come back ranked by relevance. A filter-only + request has no ``query`` or ``phrase`` to rank by, so its order is + undefined and an explicit ``sort`` is required to page reliably. + """ + return self.sort is None and self.query is None and self.phrase is None + + def to_payload(self) -> Dict[str, Any]: + """Render the request as the JSON body the API expects.""" + payload = self.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + + exact = payload.get("exact") + if exact and "metadata" in exact: + for key, values in exact.pop("metadata").items(): + exact[f"metadata[{key}]"] = values + + tags = payload.get("tags") + if tags: + payload["tags"] = { + key: value for key, value in tags.items() if value + } + + return payload diff --git a/pyuploadcare/api/tags.py b/pyuploadcare/api/tags.py new file mode 100644 index 00000000..a60c7f5f --- /dev/null +++ b/pyuploadcare/api/tags.py @@ -0,0 +1,96 @@ +import re +from typing import Iterable, List, Optional + +from pyuploadcare.exceptions import TagValidationError + + +TAG_PATTERN = r"[-_.A-Za-z0-9]" +TAG_MAX_LEN = 100 + +# Maximum amount of tags a single file can store. +MAX_TAGS_PER_FILE = 50 + +LENGTH = f"{{1,{TAG_MAX_LEN}}}" +# `\Z` rather than `$`, which would also match just before a trailing newline +# and let `"cat\n"` through. +tag_matcher = re.compile(rf"^{TAG_PATTERN}{LENGTH}\Z") + + +def normalize_tag(tag: str) -> str: + """Apply the same normalization the REST API applies to a single tag.""" + return tag.strip().lower() + + +def normalize_tags(tags: Iterable[str]) -> List[str]: + """Normalize a collection of tags the way the REST API does. + + Tags are lowercased and stripped, empty ones are discarded and duplicates + are removed keeping the first occurrence, so the original order of the + remaining tags is preserved. + """ + if isinstance(tags, str): + raise TagValidationError( + "Tags must be a collection of strings, not a single string. " + f"Got [{tags}], did you mean [{tags!r}.split(',')]?" + ) + + normalized: List[str] = [] + seen = set() + + for tag in tags: + if not isinstance(tag, str): + raise TagValidationError( + f"Tag [{tag!s}] must be string not a {type(tag)}" + ) + + normalized_tag = normalize_tag(tag) + + if not normalized_tag or normalized_tag in seen: + continue + + seen.add(normalized_tag) + normalized.append(normalized_tag) + + return normalized + + +def validate_tag(tag: str) -> None: + if not isinstance(tag, str): + raise TagValidationError( + f"Tag [{tag!s}] must be string not a {type(tag)}" + ) + + if not tag_matcher.match(tag): + raise TagValidationError( + f"Tag [{tag}] is not valid. Tags are limited to {TAG_MAX_LEN} " + "characters and may contain Latin letters, digits, `-`, `_` " + "and `.` only" + ) + + +def validate_tags( + tags: Iterable[str], + max_count: Optional[int] = MAX_TAGS_PER_FILE, +) -> List[str]: + """Normalize and validate tags, returning the normalized list. + + Tags are normalized before validation, so values the API would accept + after its own normalization (e.g. ``" Cat "``) are not rejected here. + + Args: + - tags: collection of tags to validate. + - max_count: maximum amount of tags allowed after normalization. + ``MAX_TAGS_PER_FILE`` is a limit on tags stored on a single file; + pass ``None`` where no such limit applies, e.g. for search filters. + """ + normalized = normalize_tags(tags) + + for tag in normalized: + validate_tag(tag) + + if max_count is not None and len(normalized) > max_count: + raise TagValidationError( + f"Too many tags: {len(normalized)}, maximum is {max_count}" + ) + + return normalized diff --git a/pyuploadcare/api/utils.py b/pyuploadcare/api/utils.py index 8318831d..a9602142 100644 --- a/pyuploadcare/api/utils.py +++ b/pyuploadcare/api/utils.py @@ -1,3 +1,41 @@ +from typing import Any, Optional + +from pyuploadcare.exceptions import InvalidParamError + + +def require_optional_int(name: str, value: Any) -> None: + """Reject a value that is neither ``None`` nor a real int. + + Type annotations are not enforced at runtime, and ``bool`` is a subclass + of ``int``, so ``limit=True`` would otherwise reach the query string + verbatim as ``limit=True``. + """ + if value is None: + return + + if isinstance(value, bool) or not isinstance(value, int): + raise InvalidParamError( + f"`{name}` must be an int, got {type(value).__name__}" + ) + + +def require_range( + name: str, + value: Optional[int], + minimum: Optional[int] = None, + maximum: Optional[int] = None, +) -> None: + """Reject an out-of-range value. ``None`` always passes.""" + if value is None: + return + + if minimum is not None and value < minimum: + raise InvalidParamError(f"`{name}` must be >= {minimum}, got {value}") + + if maximum is not None and value > maximum: + raise InvalidParamError(f"`{name}` must be <= {maximum}, got {value}") + + def flatten_dict(simple_mapping, attribute_base="metadata") -> dict: """ Straightforward way to use nested dict for multipart/form-data diff --git a/pyuploadcare/client.py b/pyuploadcare/client.py index 82340863..5c67c7b8 100644 --- a/pyuploadcare/client.py +++ b/pyuploadcare/client.py @@ -1,6 +1,7 @@ import os import socket import ssl +import warnings from time import time from typing import ( IO, @@ -8,6 +9,7 @@ Callable, Dict, Iterable, + Iterator, List, Optional, Tuple, @@ -24,14 +26,28 @@ GroupsAPI, MetadataAPI, ProjectAPI, + TagsAPI, UploadAPI, VideoConvertAPI, WebhooksAPI, ) -from pyuploadcare.api.api import URLAPI +from pyuploadcare.api.api import ( + SEARCH_DEFAULT_LIMIT, + SEARCH_MAX_LIMIT, + SEARCH_MAX_WINDOW, + URLAPI, +) from pyuploadcare.api.auth import UploadcareAuth from pyuploadcare.api.client import Client -from pyuploadcare.api.entities import ProjectInfo, Webhook, WebhookEvent +from pyuploadcare.api.entities import ( + FileSearchInfo, + ProjectInfo, + Webhook, + WebhookEvent, +) +from pyuploadcare.api.responses import FileSearchResponse +from pyuploadcare.api.search_entities import FileSearchRequest +from pyuploadcare.api.utils import require_optional_int, require_range from pyuploadcare.exceptions import DuplicateFileError, InvalidParamError from pyuploadcare.helpers import ( get_file_size, @@ -180,6 +196,7 @@ def __init__( self.webhooks_api = WebhooksAPI(client=self.rest_client, **api_config) # type: ignore self.project_api = ProjectAPI(client=self.rest_client, **api_config) # type: ignore self.metadata_api = MetadataAPI(client=self.rest_client, **api_config) # type: ignore + self.tags_api = TagsAPI(client=self.rest_client, **api_config) # type: ignore self.addons_api = AddonsAPI(client=self.rest_client, **api_config) # type: ignore self.url_api = URLAPI(client=self.cdn_client, **api_config) @@ -250,6 +267,7 @@ def upload( # noqa: C901 size: Optional[int] = None, callback: Optional[Callable[[UploadProgress], Any]] = None, metadata: Optional[Dict] = None, + tags: Optional[Iterable[str]] = None, ) -> "File": """Uploads a file and returns ``File`` instance. @@ -300,6 +318,16 @@ def upload( # noqa: C901 Used for multipart uploading. - callback (Optional[Callable[[UploadProgress], Any]]): Optional callback accepting ``UploadProgress`` to track uploading progress. + - tags (Optional[Iterable[str]]): Optional + `tags `_ to attach to + the uploaded file. Not supported for uploads from url; use + ``File.set_tags()`` for those. + Upload responses do not report tags back. After a direct + upload nothing is cached, so reading ``File.tags`` fetches the + file info and returns the stored tags. After a multipart + upload the info from the upload response is cached, and that + response has no tags, so ``File.tags`` is ``None`` until + ``File.update_info()`` is called. Returns: ``File`` instance @@ -308,6 +336,12 @@ def upload( # noqa: C901 # assume url is passed if str if isinstance(file_handle, str): + if tags is not None: + raise InvalidParamError( + "tags are not supported for uploads from url. " + "Use File.set_tags() after the upload instead" + ) + file_url: str = file_handle return self.upload_from_url_sync( file_url, @@ -324,7 +358,7 @@ def upload( # noqa: C901 # use direct upload for files less then multipart_min_file_size if size < self.multipart_min_file_size: files = self.upload_files( - [file_obj], store=store, common_metadata=metadata + [file_obj], store=store, common_metadata=metadata, tags=tags ) if not files: raise ValueError("Failed to get uploaded file from response") @@ -341,6 +375,7 @@ def upload( # noqa: C901 size=size, callback=callback, metadata=metadata, + tags=tags, ) return file @@ -362,6 +397,7 @@ def upload_files( file_objects: List[IO], store: Optional[bool] = None, common_metadata: Optional[Dict] = None, + tags: Optional[Iterable[str]] = None, ) -> List["File"]: """Upload multiple files using direct upload. @@ -379,6 +415,10 @@ def upload_files( - common_metadata: Dict with keys and values are all strings with constraints If presented it is set for each file from ``files`` collection + - tags (Optional[Iterable[str]]): Optional + `tags `_. + If presented they are set for each file from ``files`` + collection. Returns: ``File`` instance @@ -403,6 +443,7 @@ def _file_name(file_object, index): secure_upload=self.signed_uploads, expire=int(time()) + self.signed_uploads_ttl, common_metadata=common_metadata, + tags=tags, ) ucare_files = [self.file(response[file_name]) for file_name in files] return ucare_files @@ -415,6 +456,7 @@ def multipart_upload( # noqa: C901 mime_type: Optional[str] = None, callback: Optional[Callable[[UploadProgress], Any]] = None, metadata: Optional[Dict] = None, + tags: Optional[Iterable[str]] = None, ) -> "File": """Upload file straight to s3 by chunks. @@ -436,6 +478,8 @@ def multipart_upload( # noqa: C901 - callback (Optional[Callable[[UploadProgress], Any]]): Optional callback accepting ``UploadProgress`` to track uploading progress. - metadata (Optional[Dict]): Optional metadata + - tags (Optional[Iterable[str]]): Optional + `tags `_. Returns: ``File`` instance @@ -455,6 +499,7 @@ def multipart_upload( # noqa: C901 secure_upload=self.signed_uploads, expire=int(time()) + self.signed_uploads_ttl, metadata=metadata, + tags=tags, ) multipart_uuid = complete_response["uuid"] @@ -753,6 +798,202 @@ def list_files( removed=removed, ) + def search_files( + self, + request: Union[FileSearchRequest, Dict[str, Any]], + limit: Optional[int] = None, + offset: Optional[int] = None, + include_appdata: bool = False, + ) -> FileSearchResponse: + """Search files, returning a single page of results. + + One request can combine full-text search, exact matching, range + filters and tag filters. At least one condition is required. + + Usage example:: + + >>> from pyuploadcare import FileSearchRequest, TagsFilter + >>> response = uploadcare.search_files( + ... FileSearchRequest( + ... query='sunset', + ... tags=TagsFilter(all_=['cat']), + ... sort=['-score'], + ... ), + ... limit=50, + ... ) + >>> response.total + 2 + >>> for file_info in response.results: + ... print(file_info.original_filename, file_info.tags) + + The response is returned as is, because ``total``, ``per_page``, + ``next``, ``previous`` and the per-result ``highlight`` are all + meaningful. Use ``iterate_search_files`` to walk pages instead. + + Args: + - request: a ``FileSearchRequest`` or a dict in the same shape. + - 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. + + Returns: + ``FileSearchResponse`` + + """ + return self.files_api.search( + request, + limit=limit, + offset=offset, + include_appdata=include_appdata, + ) + + def iterate_search_files( + self, + request: Union[FileSearchRequest, Dict[str, Any]], + limit: Optional[int] = None, + request_limit: Optional[int] = None, + offset: Optional[int] = None, + include_appdata: bool = False, + ) -> Iterator[FileSearchInfo]: + """Iterate over file search results, page by page. + + Usage example:: + + >>> for file_info in uploadcare.iterate_search_files( + ... {'tags': {'all': ['cat']}, 'sort': ['-datetime_uploaded']}, + ... limit=200, + ... ): + ... print(file_info.uuid) + + Always pass an explicit ``sort`` for a filter-only request, i.e. one + without ``query`` or ``phrase``. Such a request has no relevance to + rank by, so its order is undefined, and paging through an undefined + order can skip or repeat files. A ``UserWarning`` is emitted when that + happens. + + Search is limited to the first 1000 results; narrow the query instead + of paging deeper. + + Args: + - request: a ``FileSearchRequest`` or a dict in the same shape. + - limit: total number of results to yield. ``None`` yields + everything reachable. + - request_limit: number of results retrieved per request (page). + Usually, you don't need worry about this parameter. + - offset: how many results to skip before the first page. + - include_appdata: embed application data in every result. + + """ + require_optional_int("limit", limit) + require_optional_int("request_limit", request_limit) + require_optional_int("offset", offset) + require_range("limit", limit, minimum=0) + require_range( + "request_limit", request_limit, minimum=1, maximum=SEARCH_MAX_LIMIT + ) + require_range("offset", offset, minimum=0, maximum=SEARCH_MAX_WINDOW) + + # Validate the request here rather than inside the generator, so an + # invalid request is reported immediately instead of on first + # iteration. It also keeps every page from re-validating it. + search_request = ( + request + if isinstance(request, FileSearchRequest) + else FileSearchRequest.model_validate(request) + ) + + if search_request.has_undefined_order(): + warnings.warn( + "Paging through a filter-only search without `sort` is " + "unreliable: the result order is undefined, so files may be " + "skipped or repeated. Pass an explicit `sort`.", + UserWarning, + stacklevel=2, + ) + + return self._iterate_search_files( + search_request, + limit=limit, + request_limit=request_limit, + offset=offset, + include_appdata=include_appdata, + ) + + @staticmethod + def _search_page_size( + page_size: int, current_offset: int, remaining: Optional[int] + ) -> int: + """Page size for the next search request, or 0 when done. + + Clamped to the search window, because a legal starting offset can + otherwise produce an illegal ``offset`` + ``limit`` combination, and to + what is left of the caller's total limit. + """ + size = min(page_size, SEARCH_MAX_WINDOW - current_offset) + + if remaining is not None: + size = min(size, remaining) + + return max(size, 0) + + def _iterate_search_files( # noqa: C901 + self, + request: FileSearchRequest, + limit: Optional[int], + request_limit: Optional[int], + offset: Optional[int], + include_appdata: bool, + ) -> Iterator[FileSearchInfo]: + """Walk search result pages. + + The response's ``next`` URL is deliberately not requested: it is an + absolute, server-supplied URL, and the REST client attaches + credentials to whatever URL it is given. So ``next`` is used only as a + "there is more" signal and the next offset is computed locally. + """ + page_size = ( + SEARCH_DEFAULT_LIMIT if request_limit is None else request_limit + ) + current_offset = 0 if offset is None else offset + remaining = limit + + while True: + current_page_size = self._search_page_size( + page_size, current_offset, remaining + ) + + if not current_page_size: + return + + response = self.files_api.search( + request, + limit=current_page_size, + offset=current_offset, + include_appdata=include_appdata, + ) + + if not response.results: + return + + for file_info in response.results: + yield file_info + + if remaining is not None: + remaining -= 1 + if remaining <= 0: + return + + # Advance by the requested page size, not by how many results came + # back. This is offset pagination: a short page that still reports + # a `next` would otherwise make the following request overlap it. + current_offset += current_page_size + + # `total` is not used as a stop condition: the API documents it as + # possibly approximate for very large result sets. + if response.next is None: + return + def list_file_groups( self, starting_point=None, diff --git a/pyuploadcare/exceptions.py b/pyuploadcare/exceptions.py index d75d9353..268b41c4 100644 --- a/pyuploadcare/exceptions.py +++ b/pyuploadcare/exceptions.py @@ -83,6 +83,12 @@ class MetadataValidationError(UploadcareException): """ +class TagValidationError(UploadcareException): + """ + Raised when a tag did not satisfy the constraints + """ + + class WebhookIsNotUnique(InvalidRequestError): """ Raised while creating or updating webhook diff --git a/pyuploadcare/resources/file.py b/pyuploadcare/resources/file.py index bfcb3052..8a6dd063 100644 --- a/pyuploadcare/resources/file.py +++ b/pyuploadcare/resources/file.py @@ -11,6 +11,7 @@ Face, VideoConvertInfo, ) +from pyuploadcare.api.responses import UpdateFileTagsResponse from pyuploadcare.exceptions import ( InvalidParamError, InvalidRequestError, @@ -285,6 +286,71 @@ def mime_type(self): """ return self.info.get("mime_type") + @property + def tags(self) -> Optional[List[str]]: + """Returns the file `tags`_, e.g. ``["cat", "animal"]``. + + ``[]`` when the file has no tags, ``None`` when the cached file info + does not report tags at all (upload responses do not include them). + + It might do API request once because it depends on ``info``. + + .. _tags: https://uploadcare.com/docs/file-tags/ + """ + return self.info.get("tags") + + def _cache_tags(self, tags: List[str]) -> None: + """Keep the ``tags`` property in sync after a tags mutation. + + Only touches an existing cache, so it never triggers a file info + request on its own. + """ + if self._info_cache is not None: + self._info_cache["tags"] = tags + + def get_tags(self) -> List[str]: + """Returns the file `tags`_ by requesting Uploadcare API. + + Unlike the ``tags`` property this always performs a request. + + .. _tags: https://uploadcare.com/docs/file-tags/ + """ + tags = self._client.tags_api.get(self.uuid) + self._cache_tags(tags) + return tags + + def set_tags(self, tags: List[str]) -> UpdateFileTagsResponse: + """Replaces all file tags by requesting Uploadcare API. + + Passing an empty list clears the tags:: + + >>> file_ = uploadcare.file('a771f854-c2cb-408a-8c36-71af77811f3b') + >>> file_.set_tags(['cat', 'animal']) + UpdateFileTagsResponse(tags=['cat', 'animal'], added=['cat', 'animal'], deleted=[]) + + """ + response = self._client.tags_api.replace(self.uuid, tags) + self._cache_tags(response.tags) + return response + + def update_tags( + self, + add: Optional[List[str]] = None, + delete: Optional[List[str]] = None, + ) -> UpdateFileTagsResponse: + """Adds and/or deletes file tags atomically:: + + >>> file_ = uploadcare.file('a771f854-c2cb-408a-8c36-71af77811f3b') + >>> file_.update_tags(add=['cute'], delete=['animal']) + UpdateFileTagsResponse(tags=['cat', 'cute'], added=['cute'], deleted=['animal']) + + """ + response = self._client.tags_api.update( + self.uuid, add=add, delete=delete + ) + self._cache_tags(response.tags) + return response + def store(self): """Stores file by requesting Uploadcare API. diff --git a/pyuploadcare/ucare_cli/commands/get_file_tags.py b/pyuploadcare/ucare_cli/commands/get_file_tags.py new file mode 100644 index 00000000..f35605df --- /dev/null +++ b/pyuploadcare/ucare_cli/commands/get_file_tags.py @@ -0,0 +1,14 @@ +from pyuploadcare.client import Uploadcare +from pyuploadcare.ucare_cli.commands.helpers import pprint + + +def register_arguments(subparsers): + subparser = subparsers.add_parser("get_file_tags", help="get file tags") + subparser.set_defaults(func=get_file_tags) + subparser.add_argument("path", help="file path") + return subparser + + +def get_file_tags(arg_namespace, client: Uploadcare): + file = client.file(arg_namespace.path) + pprint(file.get_tags()) diff --git a/pyuploadcare/ucare_cli/commands/helpers.py b/pyuploadcare/ucare_cli/commands/helpers.py index 61538e9d..79133ebd 100644 --- a/pyuploadcare/ucare_cli/commands/helpers.py +++ b/pyuploadcare/ucare_cli/commands/helpers.py @@ -1,3 +1,4 @@ +import argparse import json import sys import time @@ -88,6 +89,22 @@ def bool_or_none(value): return {"true": True, "false": False}.get(value) +def strict_bool(value): + """Parse a boolean, erroring out on anything unrecognised. + + Unlike ``bool_or_none``, which silently maps unknown input to ``None``. + """ + mapping = {"true": True, "false": False} + normalized = str(value).strip().lower() + + if normalized not in mapping: + raise argparse.ArgumentTypeError( + f"invalid boolean value: {value!r} (expected true or false)" + ) + + return mapping[normalized] + + def int_or_none(value): return None if value.lower() == "none" else int(value) diff --git a/pyuploadcare/ucare_cli/commands/search_files.py b/pyuploadcare/ucare_cli/commands/search_files.py new file mode 100644 index 00000000..d30c02e4 --- /dev/null +++ b/pyuploadcare/ucare_cli/commands/search_files.py @@ -0,0 +1,157 @@ +from dateutil import parser as datetime_parser +from pydantic import ValidationError + +from pyuploadcare.api.search_entities import FileSearchRequest, SearchSort +from pyuploadcare.client import Uploadcare +from pyuploadcare.exceptions import InvalidParamError +from pyuploadcare.ucare_cli.commands.helpers import pprint, strict_bool + + +def register_arguments(subparsers): # noqa: C901 + subparser = subparsers.add_parser("search_files", help="search files") + subparser.set_defaults(func=search_files) + + subparser.add_argument( + "--query", + help="full text search across all searchable fields," + " at least 4 characters", + ) + subparser.add_argument( + "--fuzziness", + action="store_true", + default=None, + help="allow approximate matches in the full text search", + ) + + for field in ( + "original_filename", + "metadata", + "detected_mime_type", + ): + subparser.add_argument( + f"--phrase_{field}", + help=f"ordered full text match on {field}," + " at least 4 characters", + ) + + for field in ("uuid", "original_filename", "detected_mime_type"): + subparser.add_argument( + f"--exact_{field}", + nargs="+", + metavar="VALUE", + help=f"exact match on {field}", + ) + + for bound in ("gt", "gte", "lt", "lte"): + subparser.add_argument( + f"--size_{bound}", + type=int, + metavar="BYTES", + help=f"file size {bound} filter, in bytes", + ) + subparser.add_argument( + f"--uploaded_{bound}", + type=datetime_parser.parse, + metavar="DATETIME", + help=f"upload datetime {bound} filter", + ) + + subparser.add_argument( + "--is_image", + type=strict_bool, + metavar="true|false", + help="filter images", + ) + + for name in ("any", "all", "none"): + subparser.add_argument( + f"--tags_{name}", + nargs="+", + metavar="TAG", + help=f"match files having {name} of these tags", + ) + + subparser.add_argument( + "--sort", + action="append", + choices=[key.value for key in SearchSort], + help="sort key, repeat for up to 4 keys. Prefix with `-` for" + " descending order, using the `--sort=-score` form so that the" + " leading dash is not read as another option", + ) + subparser.add_argument( + "--limit", + type=int, + help="results per page, 1 to 100. Defaults to 20", + ) + subparser.add_argument( + "--offset", + type=int, + help="results to skip. `offset` + `limit` must not exceed 1000", + ) + subparser.add_argument( + "--include_appdata", + action="store_true", + help="embed application data in every result", + ) + return subparser + + +def _sub_condition(arg_namespace, prefix, fields): + """Collect `--_` arguments into a nested dict.""" + condition = { + field: getattr(arg_namespace, f"{prefix}_{field}", None) + for field in fields + } + condition = { + field: value for field, value in condition.items() if value is not None + } + return condition or None + + +def _build_request(arg_namespace) -> dict: + request = { + "query": arg_namespace.query, + "fuzziness": arg_namespace.fuzziness, + "is_image": arg_namespace.is_image, + "sort": arg_namespace.sort, + "phrase": _sub_condition( + arg_namespace, + "phrase", + ("original_filename", "metadata", "detected_mime_type"), + ), + "exact": _sub_condition( + arg_namespace, + "exact", + ("uuid", "original_filename", "detected_mime_type"), + ), + "size": _sub_condition( + arg_namespace, "size", ("gt", "gte", "lt", "lte") + ), + "datetime_uploaded": _sub_condition( + arg_namespace, "uploaded", ("gt", "gte", "lt", "lte") + ), + "tags": _sub_condition(arg_namespace, "tags", ("any", "all", "none")), + } + return { + name: value for name, value in request.items() if value is not None + } + + +def search_files(arg_namespace, client: Uploadcare): + try: + request = FileSearchRequest.model_validate( + _build_request(arg_namespace) + ) + except ValidationError as error: + # `main()` only handles UploadcareException, so a raw ValidationError + # would surface as a traceback. + raise InvalidParamError(str(error)) + + response = client.search_files( + request, + limit=arg_namespace.limit, + offset=arg_namespace.offset, + include_appdata=arg_namespace.include_appdata, + ) + pprint(response.model_dump()) diff --git a/pyuploadcare/ucare_cli/commands/set_file_tags.py b/pyuploadcare/ucare_cli/commands/set_file_tags.py new file mode 100644 index 00000000..df4d39d8 --- /dev/null +++ b/pyuploadcare/ucare_cli/commands/set_file_tags.py @@ -0,0 +1,23 @@ +from pyuploadcare.client import Uploadcare +from pyuploadcare.ucare_cli.commands.helpers import pprint + + +def register_arguments(subparsers): + subparser = subparsers.add_parser( + "set_file_tags", help="replace all file tags" + ) + subparser.set_defaults(func=set_file_tags) + subparser.add_argument("path", help="file path") + subparser.add_argument( + "tags", + nargs="*", + metavar="TAG", + help="tags to set. Pass no tags to clear them", + ) + return subparser + + +def set_file_tags(arg_namespace, client: Uploadcare): + file = client.file(arg_namespace.path) + response = file.set_tags(arg_namespace.tags) + pprint(response.model_dump()) diff --git a/pyuploadcare/ucare_cli/commands/update_file_tags.py b/pyuploadcare/ucare_cli/commands/update_file_tags.py new file mode 100644 index 00000000..8f10ea6a --- /dev/null +++ b/pyuploadcare/ucare_cli/commands/update_file_tags.py @@ -0,0 +1,37 @@ +from pyuploadcare.client import Uploadcare +from pyuploadcare.exceptions import InvalidParamError +from pyuploadcare.ucare_cli.commands.helpers import pprint + + +def register_arguments(subparsers): + subparser = subparsers.add_parser( + "update_file_tags", help="add and/or delete file tags" + ) + subparser.set_defaults(func=update_file_tags) + subparser.add_argument("path", help="file path") + subparser.add_argument( + "--add", + nargs="+", + metavar="TAG", + help="tags to add", + ) + subparser.add_argument( + "--delete", + nargs="+", + metavar="TAG", + help="tags to delete", + ) + return subparser + + +def update_file_tags(arg_namespace, client: Uploadcare): + if arg_namespace.add is None and arg_namespace.delete is None: + raise InvalidParamError( + "nothing to do: pass at least one of --add or --delete" + ) + + file = client.file(arg_namespace.path) + response = file.update_tags( + add=arg_namespace.add, delete=arg_namespace.delete + ) + pprint(response.model_dump()) diff --git a/pyuploadcare/ucare_cli/commands/upload.py b/pyuploadcare/ucare_cli/commands/upload.py index e9eead94..ee15ae6b 100644 --- a/pyuploadcare/ucare_cli/commands/upload.py +++ b/pyuploadcare/ucare_cli/commands/upload.py @@ -46,6 +46,12 @@ def register_arguments(subparsers): metavar="KEY=VALUE", help="Attach metadata to the uploaded file as key=value pairs.", ) + subparser.add_argument( + "--tags", + nargs="+", + metavar="TAG", + help="Attach tags to the uploaded file.", + ) return subparser @@ -63,6 +69,8 @@ def upload(arg_namespace, client: Uploadcare): key, value = item.split("=", 1) metadata[key] = value + tags = getattr(arg_namespace, "tags", None) + with open(arg_namespace.filename, "rb") as fh: - file_ = client.upload(fh, metadata=metadata) + file_ = client.upload(fh, metadata=metadata, tags=tags) _handle_uploaded_file(file_, arg_namespace) diff --git a/pyuploadcare/ucare_cli/main.py b/pyuploadcare/ucare_cli/main.py index 6abc3e46..8d163035 100644 --- a/pyuploadcare/ucare_cli/main.py +++ b/pyuploadcare/ucare_cli/main.py @@ -17,12 +17,16 @@ delete_files, delete_webhook, get_file, + get_file_tags, get_project, list_files, list_groups, list_webhooks, + search_files, + set_file_tags, store_files, sync, + update_file_tags, update_webhook, upload, upload_from_url, @@ -58,6 +62,10 @@ def ucare_argparser(): delete_webhook.register_arguments(subparsers) create_webhook.register_arguments(subparsers) update_webhook.register_arguments(subparsers) + get_file_tags.register_arguments(subparsers) + set_file_tags.register_arguments(subparsers) + update_file_tags.register_arguments(subparsers) + search_files.register_arguments(subparsers) # common arguments parser.add_argument( diff --git a/tests/functional/api/cassettes/test_get_empty_file_tags.yaml b/tests/functional/api/cassettes/test_get_empty_file_tags.yaml new file mode 100644 index 00000000..5969040b --- /dev/null +++ b/tests/functional/api/cassettes/test_get_empty_file_tags.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.uploadcare.com + method: GET + uri: https://api.uploadcare.com/files/1a9c5240-7d9b-4473-851b-45fa4b0bed64/tags/ + response: + content: '{"tags":[]}' + headers: + Connection: + - keep-alive + Content-Length: + - '11' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_get_file_tags.yaml b/tests/functional/api/cassettes/test_get_file_tags.yaml new file mode 100644 index 00000000..d51c40bc --- /dev/null +++ b/tests/functional/api/cassettes/test_get_file_tags.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.uploadcare.com + method: GET + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '25' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_replace_file_tags.yaml b/tests/functional/api/cassettes/test_replace_file_tags.yaml new file mode 100644 index 00000000..9f5130d9 --- /dev/null +++ b/tests/functional/api/cassettes/test_replace_file_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"tags": ["cat", "animal", "cute"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PUT + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal","cute"],"added":["animal","cute"],"deleted":["dog"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '75' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_search_files.yaml b/tests/functional/api/cassettes/test_search_files.yaml new file mode 100644 index 00000000..5c746301 --- /dev/null +++ b/tests/functional/api/cassettes/test_search_files.yaml @@ -0,0 +1,31 @@ +interactions: +- request: + body: '{"query": "sunset", "tags": {"all": ["cat"]}, "sort": ["-score"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: POST + uri: https://api.uploadcare.com/files/search/?limit=2 + response: + content: '{"next":"https://api.uploadcare.com/files/search/?limit=2&offset=2","previous":null,"total":5,"per_page":2,"results":[{"uuid":"a55d6b25-d03c-4038-9838-6e06bb7df598","original_filename":"sunset-cat.jpg","size":3518420,"mime_type":"image/jpeg","is_image":true,"is_ready":true,"datetime_uploaded":"2026-02-16T14:44:29.395043Z","datetime_stored":"2026-02-16T14:44:29.637342Z","datetime_removed":null,"original_file_url":"https://ucarecdn.com/a55d6b25-d03c-4038-9838-6e06bb7df598/sunset-cat.jpg","url":"https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/","variations":null,"metadata":{"album":"holiday"},"tags":["cat","animal"],"highlight":{"original_filename":["sunset-cat.jpg"],"metadata":{"album":"summer + sunset"}}},{"uuid":"1a9c5240-7d9b-4473-851b-45fa4b0bed64","original_filename":"sunset.png","size":1024,"mime_type":"image/png","is_image":true,"is_ready":true,"datetime_uploaded":"2026-02-15T10:00:00Z","datetime_stored":null,"datetime_removed":null,"original_file_url":"https://ucarecdn.com/1a9c5240-7d9b-4473-851b-45fa4b0bed64/sunset.png","url":"https://api.uploadcare.com/files/1a9c5240-7d9b-4473-851b-45fa4b0bed64/","variations":null,"metadata":{},"tags":[],"highlight":{"original_filename":["sunset.png"]}}]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_search_files_empty_result.yaml b/tests/functional/api/cassettes/test_search_files_empty_result.yaml new file mode 100644 index 00000000..249aeb81 --- /dev/null +++ b/tests/functional/api/cassettes/test_search_files_empty_result.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"query": "nothing-matches-this"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: POST + uri: https://api.uploadcare.com/files/search/ + response: + content: '{"next":null,"previous":null,"total":0,"per_page":20,"results":[]}' + headers: + Connection: + - keep-alive + Content-Length: + - '65' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_search_files_with_appdata.yaml b/tests/functional/api/cassettes/test_search_files_with_appdata.yaml new file mode 100644 index 00000000..f5c0a8c4 --- /dev/null +++ b/tests/functional/api/cassettes/test_search_files_with_appdata.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '{"tags": {"all": ["cat"]}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: POST + uri: https://api.uploadcare.com/files/search/?include=appdata + response: + content: '{"next":null,"previous":null,"total":1,"per_page":20,"results":[{"uuid":"a55d6b25-d03c-4038-9838-6e06bb7df598","original_filename":"cat.jpg","size":1024,"mime_type":"image/jpeg","is_image":true,"is_ready":true,"tags":["cat"],"metadata":{},"appdata":{"uc_clamav_virus_scan":{"version":"0.104.2","datetime_created":"2026-02-16T14:44:29.395043Z","datetime_updated":"2026-02-16T14:44:29.637342Z","data":{"infected":false}}}}]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/cassettes/test_update_file_tags.yaml b/tests/functional/api/cassettes/test_update_file_tags.yaml new file mode 100644 index 00000000..66907ba5 --- /dev/null +++ b/tests/functional/api/cassettes/test_update_file_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"add": ["cat"], "delete": ["dog"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PATCH + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["pet","cat"],"added":["cat"],"deleted":["dog"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '55' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/api/test_search_api.py b/tests/functional/api/test_search_api.py new file mode 100644 index 00000000..48700771 --- /dev/null +++ b/tests/functional/api/test_search_api.py @@ -0,0 +1,111 @@ +"""Response parsing for file search. + +Request bodies are asserted in ``test_search_api_requests.py`` instead: VCR +matches on method and URI only. +""" + +import pytest + +from pyuploadcare.api.entities import FileSearchInfo +from pyuploadcare.api.search_entities import FileSearchRequest, TagsFilter + + +@pytest.mark.vcr +def test_search_files(uploadcare): + response = uploadcare.search_files( + FileSearchRequest( + query="sunset", tags=TagsFilter(all_=["cat"]), sort=["-score"] + ), + limit=2, + ) + + assert response.total == 5 + assert response.per_page == 2 + assert response.previous is None + assert response.next == ( + "https://api.uploadcare.com/files/search/?limit=2&offset=2" + ) + assert len(response.results) == 2 + + +def test_search_files_parses_file_info(uploadcare, vcr): + with vcr.use_cassette("test_search_files"): + response = uploadcare.search_files( + FileSearchRequest( + query="sunset", tags=TagsFilter(all_=["cat"]), sort=["-score"] + ), + limit=2, + ) + + first = response.results[0] + + assert isinstance(first, FileSearchInfo) + assert str(first.uuid) == "a55d6b25-d03c-4038-9838-6e06bb7df598" + assert first.original_filename == "sunset-cat.jpg" + assert first.size == 3518420 + assert first.is_image is True + assert first.tags == ["cat", "animal"] + assert first.metadata == {"album": "holiday"} + + +def test_search_files_parses_highlight(uploadcare, vcr): + with vcr.use_cassette("test_search_files"): + response = uploadcare.search_files( + FileSearchRequest( + query="sunset", tags=TagsFilter(all_=["cat"]), sort=["-score"] + ), + limit=2, + ) + + highlight = response.results[0].highlight + + assert highlight is not None + assert highlight.original_filename == ["sunset-cat.jpg"] + # OpenAPI declares `metadata` as an object of plain strings. + assert highlight.metadata == {"album": "summer sunset"} + # Absent for fields that did not match a full-text condition. + assert highlight.detected_mime_type is None + + +def test_search_files_handles_a_result_without_tags_or_highlight( + uploadcare, vcr +): + with vcr.use_cassette("test_search_files"): + response = uploadcare.search_files( + FileSearchRequest( + query="sunset", tags=TagsFilter(all_=["cat"]), sort=["-score"] + ), + limit=2, + ) + + second = response.results[1] + + assert second.tags == [] + assert second.datetime_stored is None + assert second.highlight is not None + assert second.highlight.metadata is None + + +@pytest.mark.vcr +def test_search_files_empty_result(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(query="nothing-matches-this") + ) + + assert response.total == 0 + assert response.results == [] + assert response.next is None + + +@pytest.mark.vcr +def test_search_files_with_appdata(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(tags=TagsFilter(all_=["cat"])), + include_appdata=True, + ) + + appdata = response.results[0].appdata + + assert appdata is not None + assert appdata.uc_clamav_virus_scan is not None + assert appdata.uc_clamav_virus_scan.data.infected is False diff --git a/tests/functional/api/test_search_api_requests.py b/tests/functional/api/test_search_api_requests.py new file mode 100644 index 00000000..323f9064 --- /dev/null +++ b/tests/functional/api/test_search_api_requests.py @@ -0,0 +1,184 @@ +"""Request shape assertions for file search. + +VCR matches on method and URI only, so the POST body has to be asserted +against a mocked client instead of a cassette. +""" + +from typing import Any, Dict +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from pyuploadcare.api.api import SEARCH_MAX_LIMIT, SEARCH_MAX_WINDOW +from pyuploadcare.api.search_entities import ( + FileSearchRequest, + SearchExact, + TagsFilter, +) +from pyuploadcare.exceptions import InvalidParamError + + +SEARCH_URL = "https://api.uploadcare.com/files/search/" + +EMPTY_PAGE: Dict[str, Any] = { + "next": None, + "previous": None, + "total": 0, + "per_page": 20, + "results": [], +} + + +def _json_response(payload=None): + response = MagicMock() + response.json.return_value = payload or EMPTY_PAGE + return response + + +@pytest.fixture +def files_api(uploadcare): + return uploadcare.files_api + + +def test_search_posts_to_the_search_url(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search(FileSearchRequest(query="sunset")) + + mocked_post.assert_called_once_with(SEARCH_URL, json={"query": "sunset"}) + + +def test_search_sends_limit_and_offset_as_query_parameters(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search( + FileSearchRequest(query="sunset"), limit=50, offset=100 + ) + + url = mocked_post.call_args.args[0] + assert url == f"{SEARCH_URL}?limit=50&offset=100" + + +def test_search_sends_include_appdata(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search( + FileSearchRequest(query="sunset"), include_appdata=True + ) + + assert mocked_post.call_args.args[0] == f"{SEARCH_URL}?include=appdata" + + +def test_search_omits_pagination_parameters_when_not_given(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search(FileSearchRequest(query="sunset")) + + assert mocked_post.call_args.args[0] == SEARCH_URL + + +def test_search_accepts_a_dict_request(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search({"tags": {"all": ["cat", "Cat"]}}) + + assert mocked_post.call_args.kwargs["json"] == {"tags": {"all": ["cat"]}} + + +def test_search_sends_bracketed_metadata_keys(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search( + FileSearchRequest(exact=SearchExact(metadata={"color": ["red"]})) + ) + + assert mocked_post.call_args.kwargs["json"] == { + "exact": {"metadata[color]": ["red"]} + } + + +def test_search_sends_tag_filter_wire_names(files_api): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search( + FileSearchRequest(tags=TagsFilter(any_=["cat"], none_=["dog"])) + ) + + assert mocked_post.call_args.kwargs["json"] == { + "tags": {"any": ["cat"], "none": ["dog"]} + } + + +def test_search_rejects_an_invalid_dict_before_any_request(files_api): + with patch.object(files_api._client, "post") as mocked_post: + with pytest.raises(ValidationError): + files_api.search({}) + + mocked_post.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": 0}, + {"limit": -1}, + {"limit": SEARCH_MAX_LIMIT + 1}, + {"offset": -1}, + {"offset": SEARCH_MAX_WINDOW, "limit": 1}, + {"offset": SEARCH_MAX_WINDOW - 10}, + {"offset": SEARCH_MAX_WINDOW - 10, "limit": 20}, + ], +) +def test_search_rejects_out_of_range_pagination(files_api, kwargs): + with patch.object(files_api._client, "post") as mocked_post: + with pytest.raises(InvalidParamError): + files_api.search(FileSearchRequest(query="sunset"), **kwargs) + + mocked_post.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": 1.5}, + {"limit": True}, + {"limit": "10"}, + {"offset": 1.5}, + {"offset": False}, + {"offset": "0"}, + ], +) +def test_search_rejects_non_integer_pagination(files_api, kwargs): + """Annotations are not enforced at runtime and `bool` is an `int`.""" + with patch.object(files_api._client, "post") as mocked_post: + with pytest.raises(InvalidParamError): + files_api.search(FileSearchRequest(query="sunset"), **kwargs) + + mocked_post.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": 1}, + {"limit": SEARCH_MAX_LIMIT}, + {"offset": 0}, + {"offset": SEARCH_MAX_WINDOW - 1, "limit": 1}, + {"offset": SEARCH_MAX_WINDOW - 20}, + ], +) +def test_search_accepts_pagination_at_the_boundaries(files_api, kwargs): + with patch.object( + files_api._client, "post", return_value=_json_response() + ) as mocked_post: + files_api.search(FileSearchRequest(query="sunset"), **kwargs) + + mocked_post.assert_called_once() diff --git a/tests/functional/api/test_search_request.py b/tests/functional/api/test_search_request.py new file mode 100644 index 00000000..5586276b --- /dev/null +++ b/tests/functional/api/test_search_request.py @@ -0,0 +1,361 @@ +"""Validation and serialization of file search request models.""" + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from pyuploadcare.api.search_entities import ( + MAX_SORT_KEYS, + DatetimeRange, + FileSearchRequest, + SearchExact, + SearchPhrase, + SearchSort, + SizeRange, + TagsFilter, +) +from pyuploadcare.exceptions import MetadataValidationError, TagValidationError + + +# --- at least one condition ------------------------------------------------- + + +def test_request_requires_at_least_one_condition(): + with pytest.raises(ValidationError): + FileSearchRequest() + + +def test_modifiers_alone_are_not_a_condition(): + """`fuzziness` and `sort` are modifiers, not conditions.""" + with pytest.raises(ValidationError): + FileSearchRequest(fuzziness=True, sort=[SearchSort.SCORE]) + + +@pytest.mark.parametrize( + "condition", + [ + {"query": "sunset"}, + {"phrase": SearchPhrase(original_filename="sunset")}, + {"exact": SearchExact(original_filename=["sunset.jpg"])}, + {"datetime_uploaded": DatetimeRange(gt=datetime(2024, 1, 1))}, + {"size": SizeRange(gt=1000)}, + {"is_image": True}, + {"is_image": False}, + {"tags": TagsFilter(any_=["cat"])}, + ], +) +def test_any_single_condition_is_enough(condition): + FileSearchRequest(**condition) + + +# --- query and phrase ------------------------------------------------------- + + +def test_query_requires_four_characters(): + with pytest.raises(ValidationError): + FileSearchRequest(query="abc") + + +def test_query_accepts_four_characters(): + assert FileSearchRequest(query="abcd").query == "abcd" + + +def test_phrase_requires_at_least_one_field(): + with pytest.raises(ValidationError): + SearchPhrase() + + +@pytest.mark.parametrize( + "field", ["original_filename", "metadata", "detected_mime_type"] +) +def test_phrase_values_require_four_characters(field): + with pytest.raises(ValidationError): + SearchPhrase(**{field: "abc"}) + + +# --- exact ------------------------------------------------------------------ + + +def test_exact_requires_at_least_one_condition(): + with pytest.raises(ValidationError): + SearchExact() + + +def test_exact_rejects_empty_metadata_mapping(): + """It would otherwise serialize to an empty `"exact": {}`.""" + with pytest.raises(ValidationError): + SearchExact(metadata={}) + + +@pytest.mark.parametrize( + "field", ["uuid", "original_filename", "detected_mime_type"] +) +def test_exact_rejects_empty_arrays(field): + with pytest.raises(ValidationError): + SearchExact(**{field: []}) + + +def test_exact_rejects_empty_metadata_value_array(): + with pytest.raises(ValidationError): + SearchExact(metadata={"color": []}) + + +def test_exact_rejects_invalid_metadata_key(): + """A `[` or `]` in a key could forge a different wire key.""" + with pytest.raises(MetadataValidationError): + SearchExact(metadata={"bad]key[": ["red"]}) + + +# --- phrase / exact overlap ------------------------------------------------- + + +@pytest.mark.parametrize("field", ["original_filename", "detected_mime_type"]) +def test_field_cannot_be_in_both_phrase_and_exact(field): + with pytest.raises(ValidationError): + FileSearchRequest( + phrase=SearchPhrase(**{field: "sunset"}), + exact=SearchExact(**{field: ["sunset"]}), + ) + + +def test_different_fields_in_phrase_and_exact_are_allowed(): + FileSearchRequest( + phrase=SearchPhrase(original_filename="sunset"), + exact=SearchExact(uuid=["a55d6b25-d03c-4038-9838-6e06bb7df598"]), + ) + + +def test_metadata_in_both_phrase_and_exact_is_allowed(): + """`phrase.metadata` and `exact.metadata[key]` are distinct wire keys.""" + FileSearchRequest( + phrase=SearchPhrase(metadata="sunset"), + exact=SearchExact(metadata={"color": ["red"]}), + ) + + +# --- sort ------------------------------------------------------------------- + + +def test_sort_rejects_empty_list(): + with pytest.raises(ValidationError): + FileSearchRequest(query="sunset", sort=[]) + + +def test_sort_rejects_too_many_keys(): + keys = [ + SearchSort.SCORE, + SearchSort.SIZE, + SearchSort.DATETIME_UPLOADED, + SearchSort.ORIGINAL_FILENAME, + SearchSort.SCORE_DESC, + ] + assert len(keys) == MAX_SORT_KEYS + 1 + + with pytest.raises(ValidationError): + FileSearchRequest(query="sunset", sort=keys) + + +def test_sort_rejects_duplicate_keys(): + with pytest.raises(ValidationError): + FileSearchRequest( + query="sunset", sort=[SearchSort.SIZE, SearchSort.SIZE] + ) + + +def test_sort_rejects_both_directions_of_the_same_key(): + with pytest.raises(ValidationError): + FileSearchRequest( + query="sunset", sort=[SearchSort.SCORE, SearchSort.SCORE_DESC] + ) + + +def test_sort_rejects_unknown_key(): + with pytest.raises(ValidationError): + FileSearchRequest(query="sunset", sort=["relevance"]) + + +def test_sort_accepts_plain_strings(): + request = FileSearchRequest(query="sunset", sort=["-score", "size"]) + assert request.sort == [SearchSort.SCORE_DESC, SearchSort.SIZE] + + +# --- tags ------------------------------------------------------------------- + + +def test_tags_filter_requires_at_least_one_list(): + with pytest.raises(ValidationError): + TagsFilter() + + +def test_tags_filter_rejects_only_empty_lists(): + with pytest.raises(ValidationError): + TagsFilter(any_=[], all_=[]) + + +def test_tags_filter_accepts_wire_names(): + tags = TagsFilter(**{"any": ["cat"], "none": ["dog"]}) + assert tags.any_ == ["cat"] + assert tags.none_ == ["dog"] + + +def test_tags_filter_normalizes_tags(): + assert TagsFilter(any_=[" Cat ", "CAT", "dog"]).any_ == ["cat", "dog"] + + +def test_tags_filter_rejects_invalid_tags(): + with pytest.raises(TagValidationError): + TagsFilter(any_=["not valid"]) + + +def test_tags_filter_has_no_count_limit(): + """The 50-tag limit is per stored file, not per filter.""" + tags = [f"tag{index}" for index in range(60)] + assert TagsFilter(any_=tags).any_ == tags + + +# --- ranges ----------------------------------------------------------------- + + +@pytest.mark.parametrize("model", [DatetimeRange, SizeRange]) +def test_range_requires_at_least_one_bound(model): + with pytest.raises(ValidationError): + model() + + +def test_size_range_rejects_negative_values(): + with pytest.raises(ValidationError): + SizeRange(gt=-1) + + +# --- strict scalars --------------------------------------------------------- + + +@pytest.mark.parametrize("value", [True, False, "1000", 1000.5]) +def test_size_range_rejects_non_integers(value): + """`gt=True` must not silently become `gt=1`.""" + with pytest.raises(ValidationError): + SizeRange(gt=value) + + +def test_size_range_accepts_integers(): + size = SizeRange(gt=0, lte=1000) + assert (size.gt, size.lte) == (0, 1000) + + +@pytest.mark.parametrize("field", ["is_image", "fuzziness"]) +@pytest.mark.parametrize("value", [1, 0, "true", "false", "yes"]) +def test_boolean_fields_reject_non_booleans(field, value): + with pytest.raises(ValidationError): + FileSearchRequest(query="sunset", **{field: value}) + + +@pytest.mark.parametrize("field", ["is_image", "fuzziness"]) +@pytest.mark.parametrize("value", [True, False]) +def test_boolean_fields_accept_booleans(field, value): + request = FileSearchRequest(query="sunset", **{field: value}) + assert getattr(request, field) is value + + +# --- extra fields ----------------------------------------------------------- + + +def test_unknown_field_is_rejected(): + """A typo must be reported, not silently dropped.""" + with pytest.raises(ValidationError): + FileSearchRequest.model_validate({"querry": "sunset"}) + + +def test_unknown_nested_field_is_rejected(): + with pytest.raises(ValidationError): + FileSearchRequest.model_validate( + {"exact": {"filename": ["sunset.jpg"]}} + ) + + +def test_wire_shape_for_exact_metadata_is_rejected(): + """Dicts use the SDK shape, not the `metadata[key]` wire shape.""" + with pytest.raises(ValidationError): + FileSearchRequest.model_validate( + {"exact": {"metadata[color]": ["red"]}} + ) + + +# --- to_payload ------------------------------------------------------------- + + +def test_payload_lifts_exact_metadata_into_bracketed_keys(): + request = FileSearchRequest( + exact=SearchExact(metadata={"color": ["red"], "size": ["xl"]}) + ) + + assert request.to_payload() == { + "exact": {"metadata[color]": ["red"], "metadata[size]": ["xl"]} + } + + +def test_payload_keeps_other_exact_fields_alongside_metadata(): + request = FileSearchRequest( + exact=SearchExact( + original_filename=["sunset.jpg"], metadata={"color": ["red"]} + ) + ) + + assert request.to_payload() == { + "exact": { + "original_filename": ["sunset.jpg"], + "metadata[color]": ["red"], + } + } + + +def test_payload_renders_datetime_as_iso8601(): + request = FileSearchRequest( + datetime_uploaded=DatetimeRange( + gte=datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + ) + ) + + payload = request.to_payload() + assert payload["datetime_uploaded"]["gte"].startswith( + "2024-01-02T03:04:05" + ) + + +def test_payload_renders_sort_keys_as_values(): + request = FileSearchRequest( + query="sunset", sort=[SearchSort.SCORE_DESC, SearchSort.SIZE] + ) + + assert request.to_payload()["sort"] == ["-score", "size"] + + +def test_payload_uses_tag_filter_wire_names(): + request = FileSearchRequest( + tags=TagsFilter(any_=["cat"], all_=["pet"], none_=["dog"]) + ) + + assert request.to_payload()["tags"] == { + "any": ["cat"], + "all": ["pet"], + "none": ["dog"], + } + + +def test_payload_drops_empty_tag_lists(): + request = FileSearchRequest(tags=TagsFilter(any_=["cat"], none_=[])) + + assert request.to_payload()["tags"] == {"any": ["cat"]} + + +def test_payload_omits_unset_fields(): + request = FileSearchRequest(query="sunset") + + assert request.to_payload() == {"query": "sunset"} + + +def test_payload_keeps_false_values(): + """`exclude_none` must not drop `is_image=False` or `fuzziness=False`.""" + request = FileSearchRequest(is_image=False, fuzziness=False) + + assert request.to_payload() == {"is_image": False, "fuzziness": False} diff --git a/tests/functional/api/test_tags_api.py b/tests/functional/api/test_tags_api.py new file mode 100644 index 00000000..a7c45161 --- /dev/null +++ b/tests/functional/api/test_tags_api.py @@ -0,0 +1,47 @@ +"""Response parsing for ``TagsAPI``. + +Request bodies are asserted in ``test_tags_api_requests.py`` instead: VCR +matches on method and URI only. +""" + +import pytest + +from pyuploadcare.api.responses import UpdateFileTagsResponse + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + + +@pytest.mark.vcr +def test_get_file_tags(uploadcare): + assert uploadcare.tags_api.get(FILE_UUID) == ["cat", "animal"] + + +@pytest.mark.vcr +def test_get_empty_file_tags(uploadcare): + tags = uploadcare.tags_api.get("1a9c5240-7d9b-4473-851b-45fa4b0bed64") + assert tags == [] + + +@pytest.mark.vcr +def test_replace_file_tags(uploadcare): + response = uploadcare.tags_api.replace( + FILE_UUID, ["cat", "animal", "cute"] + ) + + assert isinstance(response, UpdateFileTagsResponse) + assert response.tags == ["cat", "animal", "cute"] + assert response.added == ["animal", "cute"] + assert response.deleted == ["dog"] + + +@pytest.mark.vcr +def test_update_file_tags(uploadcare): + response = uploadcare.tags_api.update( + FILE_UUID, add=["cat"], delete=["dog"] + ) + + assert isinstance(response, UpdateFileTagsResponse) + assert response.tags == ["pet", "cat"] + assert response.added == ["cat"] + assert response.deleted == ["dog"] diff --git a/tests/functional/api/test_tags_api_requests.py b/tests/functional/api/test_tags_api_requests.py new file mode 100644 index 00000000..efd1c3f3 --- /dev/null +++ b/tests/functional/api/test_tags_api_requests.py @@ -0,0 +1,193 @@ +"""Request shape assertions for ``TagsAPI``. + +VCR matches on method and URI only, so the request body has to be asserted +against a mocked client instead of a cassette. +""" + +from unittest.mock import MagicMock, patch +from uuid import UUID + +import pytest + +from pyuploadcare.api.tags import MAX_TAGS_PER_FILE +from pyuploadcare.exceptions import InvalidParamError, TagValidationError + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" +TAGS_URL = f"https://api.uploadcare.com/files/{FILE_UUID}/tags/" + + +def _json_response(payload): + response = MagicMock() + response.json.return_value = payload + return response + + +@pytest.fixture +def tags_api(uploadcare): + return uploadcare.tags_api + + +def test_get_requests_tags_url(tags_api): + with patch.object( + tags_api._client, + "get", + return_value=_json_response({"tags": ["cat", "animal"]}), + ) as mocked_get: + assert tags_api.get(FILE_UUID) == ["cat", "animal"] + + mocked_get.assert_called_once_with(TAGS_URL) + + +def test_get_accepts_uuid_instance(tags_api): + with patch.object( + tags_api._client, "get", return_value=_json_response({"tags": []}) + ) as mocked_get: + assert tags_api.get(UUID(FILE_UUID)) == [] + + mocked_get.assert_called_once_with(TAGS_URL) + + +def test_replace_sends_normalized_tags(tags_api): + payload = {"tags": ["cat", "animal"], "added": ["animal"], "deleted": []} + + with patch.object( + tags_api._client, "put", return_value=_json_response(payload) + ) as mocked_put: + response = tags_api.replace(FILE_UUID, [" Cat ", "ANIMAL", "cat"]) + + mocked_put.assert_called_once_with( + TAGS_URL, json={"tags": ["cat", "animal"]} + ) + assert response.tags == ["cat", "animal"] + assert response.added == ["animal"] + assert response.deleted == [] + + +def test_replace_with_empty_list_clears_tags(tags_api): + payload = {"tags": [], "added": [], "deleted": ["cat"]} + + with patch.object( + tags_api._client, "put", return_value=_json_response(payload) + ) as mocked_put: + response = tags_api.replace(FILE_UUID, []) + + mocked_put.assert_called_once_with(TAGS_URL, json={"tags": []}) + assert response.deleted == ["cat"] + + +def test_update_sends_add_and_delete(tags_api): + payload = {"tags": ["cat"], "added": ["cat"], "deleted": ["dog"]} + + with patch.object( + tags_api._client, "patch", return_value=_json_response(payload) + ) as mocked_patch: + response = tags_api.update(FILE_UUID, add=["Cat"], delete=["dog"]) + + mocked_patch.assert_called_once_with( + TAGS_URL, json={"add": ["cat"], "delete": ["dog"]} + ) + assert response.added == ["cat"] + + +def test_update_omits_delete_when_not_given(tags_api): + payload = {"tags": ["cat"], "added": ["cat"], "deleted": []} + + with patch.object( + tags_api._client, "patch", return_value=_json_response(payload) + ) as mocked_patch: + tags_api.update(FILE_UUID, add=["cat"]) + + mocked_patch.assert_called_once_with(TAGS_URL, json={"add": ["cat"]}) + + +def test_update_omits_add_when_not_given(tags_api): + payload = {"tags": [], "added": [], "deleted": ["cat"]} + + with patch.object( + tags_api._client, "patch", return_value=_json_response(payload) + ) as mocked_patch: + tags_api.update(FILE_UUID, delete=["cat"]) + + mocked_patch.assert_called_once_with(TAGS_URL, json={"delete": ["cat"]}) + + +def test_update_allows_more_delete_candidates_than_the_storage_limit( + tags_api, +): + """`delete` lists candidates; absent tags are ignored server-side. + + So it may legitimately be longer than the 50-tags-per-file limit. + """ + delete = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 1)] + payload = {"tags": [], "added": [], "deleted": delete} + + with patch.object( + tags_api._client, "patch", return_value=_json_response(payload) + ) as mocked_patch: + tags_api.update(FILE_UUID, delete=delete) + + mocked_patch.assert_called_once_with(TAGS_URL, json={"delete": delete}) + + +def test_replace_still_enforces_the_storage_limit(tags_api): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 1)] + + with patch.object(tags_api._client, "put") as mocked_put: + with pytest.raises(TagValidationError): + tags_api.replace(FILE_UUID, tags) + + mocked_put.assert_not_called() + + +def test_update_still_enforces_the_storage_limit_for_add(tags_api): + """An `add` list longer than the limit can never succeed.""" + add = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 1)] + + with patch.object(tags_api._client, "patch") as mocked_patch: + with pytest.raises(TagValidationError): + tags_api.update(FILE_UUID, add=add) + + mocked_patch.assert_not_called() + + +def test_update_without_arguments_sends_empty_body(tags_api): + """The endpoint documents both fields as optional, so `{}` is valid.""" + payload = {"tags": ["cat"], "added": [], "deleted": []} + + with patch.object( + tags_api._client, "patch", return_value=_json_response(payload) + ) as mocked_patch: + response = tags_api.update(FILE_UUID) + + mocked_patch.assert_called_once_with(TAGS_URL, json={}) + assert response.tags == ["cat"] + + +@pytest.mark.parametrize( + "file_uuid", + [ + "not-a-uuid", + "//evil.example/files/x", + "https://evil.example/files/x/", + "../../files", + "", + None, + 42, + ], +) +def test_invalid_uuid_is_rejected_before_any_request(tags_api, file_uuid): + """`_build_url` uses `urljoin`, so a crafted id could change the origin.""" + with patch.object(tags_api._client, "get") as mocked_get: + with pytest.raises(InvalidParamError): + tags_api.get(file_uuid) + + mocked_get.assert_not_called() + + +def test_invalid_tags_are_rejected_before_any_request(tags_api): + with patch.object(tags_api._client, "put") as mocked_put: + with pytest.raises(TagValidationError): + tags_api.replace(FILE_UUID, ["not valid"]) + + mocked_put.assert_not_called() diff --git a/tests/functional/api/test_tags_validation.py b/tests/functional/api/test_tags_validation.py new file mode 100644 index 00000000..57f3d17f --- /dev/null +++ b/tests/functional/api/test_tags_validation.py @@ -0,0 +1,126 @@ +import pytest + +from pyuploadcare.api.tags import ( + MAX_TAGS_PER_FILE, + TAG_MAX_LEN, + normalize_tag, + normalize_tags, + validate_tag, + validate_tags, +) +from pyuploadcare.exceptions import TagValidationError + + +def test_normalize_tag_lowercases_and_strips(): + assert normalize_tag(" Cat ") == "cat" + + +def test_normalize_tags_lowercases(): + assert normalize_tags(["cat", "Cat", "CAT"]) == ["cat"] + + +def test_normalize_tags_strips_whitespace(): + assert normalize_tags([" cat ", "\tanimal\n"]) == ["cat", "animal"] + + +def test_normalize_tags_discards_empty_strings(): + assert normalize_tags(["cat", "", " ", "animal"]) == ["cat", "animal"] + + +def test_normalize_tags_preserves_first_seen_order(): + assert normalize_tags(["dog", "cat", "Dog", "animal"]) == [ + "dog", + "cat", + "animal", + ] + + +def test_normalize_tags_accepts_any_iterable(): + assert normalize_tags(("cat", "animal")) == ["cat", "animal"] + assert normalize_tags(tag for tag in ["cat", "animal"]) == [ + "cat", + "animal", + ] + + +def test_normalize_tags_rejects_a_bare_string(): + with pytest.raises(TagValidationError): + normalize_tags("cat,animal") + + +def test_normalize_tags_rejects_non_string_items(): + with pytest.raises(TagValidationError): + normalize_tags(["cat", 42]) # type: ignore[list-item] + + +@pytest.mark.parametrize( + "tag", ["cat", "animal-2", "under_score", "with.dot", "MiXeD", "0"] +) +def test_validate_tag_accepts_allowed_characters(tag): + validate_tag(tag) + + +@pytest.mark.parametrize( + "tag", + [ + "with space", + "with+plus", + "with@at", + "with/slash", + "with,comma", + "кот", + "with:colon", + "", + "cat\n", + "\ncat", + "cat\t", + ], +) +def test_validate_tag_rejects_disallowed_characters(tag): + with pytest.raises(TagValidationError): + validate_tag(tag) + + +def test_validate_tag_rejects_non_string(): + with pytest.raises(TagValidationError): + validate_tag(42) # type: ignore[arg-type] + + +def test_validate_tags_accepts_max_length_tag(): + tag = "a" * TAG_MAX_LEN + assert validate_tags([tag]) == [tag] + + +def test_validate_tags_rejects_too_long_tag(): + with pytest.raises(TagValidationError): + validate_tags(["a" * (TAG_MAX_LEN + 1)]) + + +def test_validate_tags_returns_normalized_list(): + assert validate_tags([" Cat ", "CAT", "animal"]) == ["cat", "animal"] + + +def test_validate_tags_accepts_max_amount_of_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE)] + assert validate_tags(tags) == tags + + +def test_validate_tags_rejects_too_many_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 1)] + with pytest.raises(TagValidationError): + validate_tags(tags) + + +def test_validate_tags_counts_tags_after_deduplication(): + """51 tags collapsing into 50 unique ones is within the limit.""" + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE)] + assert validate_tags([*tags, "TAG0"]) == tags + + +def test_validate_tags_without_max_count_allows_more_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 10)] + assert validate_tags(tags, max_count=None) == tags + + +def test_validate_tags_accepts_empty_collection(): + assert validate_tags([]) == [] diff --git a/tests/functional/api/test_upload_api_with_tags.py b/tests/functional/api/test_upload_api_with_tags.py new file mode 100644 index 00000000..b699897f --- /dev/null +++ b/tests/functional/api/test_upload_api_with_tags.py @@ -0,0 +1,102 @@ +"""Request shape assertions for tags in the Upload API. + +Tags are sent as a comma-separated ``tags`` form field, which VCR's default +matcher does not check, so a mocked client is used instead of a cassette. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from pyuploadcare.exceptions import TagValidationError + + +def _upload_response(payload=None): + response = MagicMock() + response.json.return_value = payload or {} + return response + + +@pytest.fixture +def upload_api(uploadcare): + return uploadcare.upload_api + + +def _sent_data(mocked_post): + return mocked_post.call_args.kwargs["data"] + + +def test_upload_sends_comma_separated_tags(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=["cat", "animal"]) + + assert _sent_data(mocked_post)["tags"] == "cat,animal" + + +def test_upload_normalizes_tags(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=[" Cat ", "CAT", "dog"]) + + assert _sent_data(mocked_post)["tags"] == "cat,dog" + + +def test_upload_omits_tags_when_not_given(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}) + + assert "tags" not in _sent_data(mocked_post) + + +@pytest.mark.parametrize("tags", [[], ["", " "]]) +def test_upload_omits_tags_when_they_normalize_to_empty( + upload_api, small_file, tags +): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=tags) + + assert "tags" not in _sent_data(mocked_post) + + +def test_upload_rejects_invalid_tags(upload_api, small_file): + with patch.object(upload_api._client, "post") as mocked_post: + with open(small_file.name, "rb") as fh: + with pytest.raises(TagValidationError): + upload_api.upload({"file.txt": fh}, tags=["not valid"]) + + mocked_post.assert_not_called() + + +def test_start_multipart_upload_sends_comma_separated_tags(upload_api): + with patch.object( + upload_api._client, + "post", + return_value=_upload_response({"uuid": "x", "parts": []}), + ) as mocked_post: + upload_api.start_multipart_upload( + "file.txt", 100, "text/plain", tags=["cat", "animal"] + ) + + assert _sent_data(mocked_post)["tags"] == "cat,animal" + + +def test_start_multipart_upload_omits_tags_when_not_given(upload_api): + with patch.object( + upload_api._client, + "post", + return_value=_upload_response({"uuid": "x", "parts": []}), + ) as mocked_post: + upload_api.start_multipart_upload("file.txt", 100, "text/plain") + + assert "tags" not in _sent_data(mocked_post) diff --git a/tests/functional/resources/cassettes/test_file_get_tags.yaml b/tests/functional/resources/cassettes/test_file_get_tags.yaml new file mode 100644 index 00000000..d51c40bc --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_get_tags.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.uploadcare.com + method: GET + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '25' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/cassettes/test_file_set_tags.yaml b/tests/functional/resources/cassettes/test_file_set_tags.yaml new file mode 100644 index 00000000..1fa362a4 --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_set_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"tags": ["cat", "cute"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PUT + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","cute"],"added":["cute"],"deleted":["animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '60' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/cassettes/test_file_update_tags.yaml b/tests/functional/resources/cassettes/test_file_update_tags.yaml new file mode 100644 index 00000000..d6fc2f1b --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_update_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"add": ["cute"], "delete": ["animal"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PATCH + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","cute"],"added":["cute"],"deleted":["animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '60' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/test_file_tags.py b/tests/functional/resources/test_file_tags.py new file mode 100644 index 00000000..017caf05 --- /dev/null +++ b/tests/functional/resources/test_file_tags.py @@ -0,0 +1,91 @@ +import pytest + +from pyuploadcare.api.entities import FileInfo + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + +FILE_INFO_WITH_TAGS = { + "uuid": FILE_UUID, + "original_filename": "sample.jpg", + "tags": ["cat", "animal"], +} + + +def test_file_info_parses_tags(): + file_info = FileInfo.model_validate(FILE_INFO_WITH_TAGS) + assert file_info.tags == ["cat", "animal"] + + +def test_file_info_parses_empty_tags(): + file_info = FileInfo.model_validate({"uuid": FILE_UUID, "tags": []}) + assert file_info.tags == [] + + +def test_file_info_without_tags_dumps_none(): + """Endpoints that do not report tags leave the field as ``None``. + + Regression guard: ``model_dump()`` gained a new ``tags`` key, which + consumers of ``File.info`` will now see. + """ + file_info = FileInfo.model_validate({"uuid": FILE_UUID}) + assert file_info.tags is None + assert file_info.model_dump()["tags"] is None + + +def test_file_tags_property_reads_from_info(uploadcare): + file_ = uploadcare.file(FILE_UUID, FILE_INFO_WITH_TAGS) + assert file_.tags == ["cat", "animal"] + + +@pytest.mark.vcr +def test_file_get_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + assert file_.get_tags() == ["cat", "animal"] + + +def test_file_get_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, {"uuid": FILE_UUID, "tags": []}) + + with vcr.use_cassette("test_file_get_tags"): + assert file_.get_tags() == ["cat", "animal"] + + assert file_.info["tags"] == ["cat", "animal"] + + +@pytest.mark.vcr +def test_file_set_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + response = file_.set_tags(["cat", "cute"]) + + assert response.tags == ["cat", "cute"] + assert response.added == ["cute"] + assert response.deleted == ["animal"] + + +def test_file_set_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, dict(FILE_INFO_WITH_TAGS)) + + with vcr.use_cassette("test_file_set_tags"): + file_.set_tags(["cat", "cute"]) + + assert file_.info["tags"] == ["cat", "cute"] + + +@pytest.mark.vcr +def test_file_update_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + response = file_.update_tags(add=["cute"], delete=["animal"]) + + assert response.tags == ["cat", "cute"] + assert response.added == ["cute"] + assert response.deleted == ["animal"] + + +def test_file_update_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, dict(FILE_INFO_WITH_TAGS)) + + with vcr.use_cassette("test_file_update_tags"): + file_.update_tags(add=["cute"], delete=["animal"]) + + assert file_.info["tags"] == ["cat", "cute"] diff --git a/tests/functional/resources/test_file_tags_propagation.py b/tests/functional/resources/test_file_tags_propagation.py new file mode 100644 index 00000000..9915555a --- /dev/null +++ b/tests/functional/resources/test_file_tags_propagation.py @@ -0,0 +1,149 @@ +"""``tags`` must reach the Upload API through every public upload wrapper.""" + +from unittest.mock import patch + +import pytest + +from pyuploadcare.exceptions import InvalidParamError + + +TAGS = ["cat", "animal"] +UPLOADED_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + + +def test_upload_files_forwards_tags(uploadcare, small_file): + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload_files([fh], tags=TAGS) + + assert mocked_upload.call_args.kwargs["tags"] == TAGS + + +def test_upload_files_omits_tags_when_not_given(uploadcare, small_file): + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload_files([fh]) + + assert mocked_upload.call_args.kwargs["tags"] is None + + +def test_direct_upload_forwards_tags(uploadcare, small_file): + """Files below ``multipart_min_file_size`` take the direct upload path.""" + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload(fh, tags=TAGS) + + assert mocked_upload.call_args.kwargs["tags"] == TAGS + + +def _patched_multipart(uploadcare): + """Patch the three Upload API calls a multipart upload makes.""" + return ( + patch.object( + uploadcare.upload_api, + "start_multipart_upload", + return_value={ + "uuid": UPLOADED_UUID, + "parts": ["https://s3.example/part-1"], + }, + ), + patch.object(uploadcare.upload_api, "multipart_upload_chunk"), + patch.object( + uploadcare.upload_api, + "multipart_complete", + return_value={"uuid": UPLOADED_UUID}, + ), + ) + + +def test_multipart_upload_forwards_tags(uploadcare, memo_file): + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with start as mocked_start, chunk, complete: + uploadcare.multipart_upload(stream, size=size, tags=TAGS) + + assert mocked_start.call_args.kwargs["tags"] == TAGS + + +def test_upload_uses_multipart_path_for_big_files(uploadcare, memo_file): + """``upload()`` must forward tags on the multipart branch too.""" + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with patch.object(uploadcare, "multipart_min_file_size", 1): + with start as mocked_start, chunk, complete: + uploadcare.upload(stream, size=size, tags=TAGS) + + assert mocked_start.call_args.kwargs["tags"] == TAGS + + +def test_file_tags_after_direct_upload_fetches_info(uploadcare, small_file): + """A direct upload caches nothing, so `File.tags` fetches the info. + + Upload responses never report tags, but the direct path leaves + `_info_cache` unset, so reading `tags` goes to `GET /files/{uuid}/`. + """ + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ): + with open(small_file.name, "rb") as fh: + file_ = uploadcare.upload(fh, tags=TAGS) + + assert file_._info_cache is None + + with patch.object(uploadcare.files_api, "retrieve") as mocked_retrieve: + mocked_retrieve.return_value.model_dump.return_value = { + "uuid": UPLOADED_UUID, + "tags": TAGS, + } + assert file_.tags == TAGS + + mocked_retrieve.assert_called_once() + + +def test_file_tags_after_multipart_upload_is_none(uploadcare, memo_file): + """A multipart upload caches its own response, which has no tags. + + So `File.tags` reports `None` until the info is refreshed. + """ + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with start, chunk, complete: + file_ = uploadcare.multipart_upload(stream, size=size, tags=TAGS) + + # Cached from the upload response, which carries no `tags` key. + assert file_._info_cache is not None + assert "tags" not in file_._info_cache + assert file_.tags is None + + +def test_upload_from_url_rejects_tags(uploadcare): + """`/from_url/` does not support tags, so they must not be dropped.""" + with patch.object(uploadcare, "upload_from_url_sync") as mocked_upload: + with pytest.raises(InvalidParamError): + uploadcare.upload("https://example.com/file.jpg", tags=TAGS) + + mocked_upload.assert_not_called() + + +def test_upload_from_url_without_tags_still_works(uploadcare): + with patch.object(uploadcare, "upload_from_url_sync") as mocked_upload: + uploadcare.upload("https://example.com/file.jpg") + + mocked_upload.assert_called_once() diff --git a/tests/functional/resources/test_search_pagination.py b/tests/functional/resources/test_search_pagination.py new file mode 100644 index 00000000..6acb6731 --- /dev/null +++ b/tests/functional/resources/test_search_pagination.py @@ -0,0 +1,336 @@ +"""Pagination behaviour of ``Uploadcare.iterate_search_files``.""" + +from unittest.mock import patch +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from pyuploadcare.api.api import ( + SEARCH_DEFAULT_LIMIT, + SEARCH_MAX_LIMIT, + SEARCH_MAX_WINDOW, +) +from pyuploadcare.api.responses import FileSearchResponse +from pyuploadcare.exceptions import InvalidParamError + + +# Filter-only requests have an undefined order, so every request used for +# paging carries an explicit `sort`. See `test_undefined_order_warning`. +REQUEST = {"tags": {"all": ["cat"]}, "sort": ["-datetime_uploaded"]} + +# A `next` URL pointing somewhere else entirely. The iterator must never +# request it: the REST client attaches credentials to any URL it is given. +HOSTILE_NEXT = "https://evil.example/files/search/?limit=2&offset=999" + + +def _uuid(index: int) -> str: + return str(UUID(int=index)) + + +def _page(count, next_url=None, total=None, offset=0, per_page=None): + return FileSearchResponse.model_validate( + { + "next": next_url, + "previous": None, + "total": count if total is None else total, + "per_page": count if per_page is None else per_page, + "results": [ + {"uuid": _uuid(offset + index)} for index in range(count) + ], + } + ) + + +def _patched_search(uploadcare, pages): + return patch.object(uploadcare.files_api, "search", side_effect=pages) + + +def _offsets(mocked_search): + return [call.kwargs["offset"] for call in mocked_search.call_args_list] + + +def _limits(mocked_search): + return [call.kwargs["limit"] for call in mocked_search.call_args_list] + + +def test_single_page_is_yielded(uploadcare): + with _patched_search(uploadcare, [_page(3)]): + results = list(uploadcare.iterate_search_files(REQUEST)) + + assert len(results) == 3 + + +def test_follows_pages_until_next_is_none(uploadcare): + pages = [ + _page(2, next_url=HOSTILE_NEXT, total=4), + _page(2, next_url=None, total=4, offset=2), + ] + + with _patched_search(uploadcare, pages) as mocked_search: + results = list( + uploadcare.iterate_search_files(REQUEST, request_limit=2) + ) + + assert [str(item.uuid) for item in results] == [_uuid(i) for i in range(4)] + assert _offsets(mocked_search) == [0, 2] + + +def test_offset_advances_by_the_requested_page_size(uploadcare): + """Offset pagination advances by the window, not by results received. + + A short page that still reports a `next` would otherwise make the + following request overlap it and repeat files. + """ + pages = [ + # Asked for 20, got 3, but there is more. + _page(3, next_url=HOSTILE_NEXT, total=99, per_page=20), + _page(0, next_url=None, total=99, per_page=20), + ] + + with _patched_search(uploadcare, pages) as mocked_search: + list(uploadcare.iterate_search_files(REQUEST)) + + assert _offsets(mocked_search) == [0, SEARCH_DEFAULT_LIMIT] + + +def test_next_url_is_never_requested(uploadcare): + """Offsets are computed locally, never taken from the `next` URL.""" + pages = [ + _page(2, next_url=HOSTILE_NEXT, total=4), + _page(2, next_url=None, total=4, offset=2), + ] + + with _patched_search(uploadcare, pages) as mocked_search: + with patch.object(uploadcare.rest_client, "post") as mocked_post: + list(uploadcare.iterate_search_files(REQUEST, request_limit=2)) + + mocked_post.assert_not_called() + # `HOSTILE_NEXT` advertises offset 999 on another origin; ignored. + assert _offsets(mocked_search) == [0, 2] + + +def test_stops_on_an_empty_page(uploadcare): + pages = [ + _page(2, next_url=HOSTILE_NEXT, total=99), + _page(0, next_url=HOSTILE_NEXT, total=99), + ] + + with _patched_search(uploadcare, pages) as mocked_search: + results = list( + uploadcare.iterate_search_files(REQUEST, request_limit=2) + ) + + assert len(results) == 2 + assert mocked_search.call_count == 2 + + +def test_does_not_stop_on_an_understated_total(uploadcare): + """`total` is documented as possibly approximate, so it is not a stop.""" + pages = [ + _page(2, next_url=HOSTILE_NEXT, total=2), + _page(2, next_url=None, total=2, offset=2), + ] + + with _patched_search(uploadcare, pages): + results = list( + uploadcare.iterate_search_files(REQUEST, request_limit=2) + ) + + assert len(results) == 4 + + +def test_limit_caps_the_total_yielded(uploadcare): + pages = [_page(2, next_url=HOSTILE_NEXT, total=99)] + + with _patched_search(uploadcare, pages) as mocked_search: + results = list(uploadcare.iterate_search_files(REQUEST, limit=2)) + + assert len(results) == 2 + assert mocked_search.call_count == 1 + + +def test_limit_smaller_than_a_page_truncates_mid_page(uploadcare): + pages = [_page(5, next_url=HOSTILE_NEXT, total=99)] + + with _patched_search(uploadcare, pages) as mocked_search: + results = list(uploadcare.iterate_search_files(REQUEST, limit=3)) + + assert len(results) == 3 + # The page size is clamped to the remaining amount. + assert _limits(mocked_search) == [3] + + +def test_limit_zero_makes_no_request(uploadcare): + with patch.object(uploadcare.files_api, "search") as mocked_search: + results = list(uploadcare.iterate_search_files(REQUEST, limit=0)) + + assert results == [] + mocked_search.assert_not_called() + + +def test_default_page_size(uploadcare): + with _patched_search(uploadcare, [_page(1)]) as mocked_search: + list(uploadcare.iterate_search_files(REQUEST)) + + assert _limits(mocked_search) == [SEARCH_DEFAULT_LIMIT] + + +def test_request_limit_sets_the_page_size(uploadcare): + with _patched_search(uploadcare, [_page(1)]) as mocked_search: + list(uploadcare.iterate_search_files(REQUEST, request_limit=7)) + + assert _limits(mocked_search) == [7] + + +def test_offset_is_forwarded(uploadcare): + with _patched_search(uploadcare, [_page(1)]) as mocked_search: + list(uploadcare.iterate_search_files(REQUEST, offset=42)) + + assert _offsets(mocked_search) == [42] + + +@pytest.mark.parametrize( + "offset, expected_page_size", + [ + (SEARCH_MAX_WINDOW - 10, 10), + (SEARCH_MAX_WINDOW - 50, 20), + (SEARCH_MAX_WINDOW - 1, 1), + ], +) +def test_page_size_is_clamped_to_the_search_window( + uploadcare, offset, expected_page_size +): + """`offset` + `limit` must never exceed the window.""" + with _patched_search(uploadcare, [_page(1, offset=offset)]) as mocked: + list(uploadcare.iterate_search_files(REQUEST, offset=offset)) + + call = mocked.call_args_list[0] + assert call.kwargs["limit"] == expected_page_size + assert call.kwargs["offset"] + call.kwargs["limit"] <= SEARCH_MAX_WINDOW + + +def test_stops_at_the_search_window(uploadcare): + """Paging cannot go past the first 1000 results.""" + offset = SEARCH_MAX_WINDOW - 2 + pages = [_page(2, next_url=HOSTILE_NEXT, total=99, offset=offset)] + + with _patched_search(uploadcare, pages) as mocked_search: + results = list(uploadcare.iterate_search_files(REQUEST, offset=offset)) + + assert len(results) == 2 + assert mocked_search.call_count == 1 + + +def test_offset_at_the_window_makes_no_request(uploadcare): + with patch.object(uploadcare.files_api, "search") as mocked_search: + results = list( + uploadcare.iterate_search_files(REQUEST, offset=SEARCH_MAX_WINDOW) + ) + + assert results == [] + mocked_search.assert_not_called() + + +# --- argument and request validation ---------------------------------------- + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": -1}, + {"request_limit": 0}, + {"request_limit": -1}, + {"request_limit": SEARCH_MAX_LIMIT + 1}, + {"offset": -1}, + {"offset": SEARCH_MAX_WINDOW + 1}, + ], +) +def test_rejects_out_of_range_arguments(uploadcare, kwargs): + with patch.object(uploadcare.files_api, "search") as mocked_search: + with pytest.raises(InvalidParamError): + uploadcare.iterate_search_files(REQUEST, **kwargs) + + mocked_search.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"limit": 1.5}, + {"limit": True}, + {"request_limit": "10"}, + {"request_limit": False}, + {"offset": 2.5}, + ], +) +def test_rejects_non_integer_arguments(uploadcare, kwargs): + with patch.object(uploadcare.files_api, "search") as mocked_search: + with pytest.raises(InvalidParamError): + uploadcare.iterate_search_files(REQUEST, **kwargs) + + mocked_search.assert_not_called() + + +def test_argument_errors_are_raised_eagerly(uploadcare): + """Not deferred until the returned iterator is first advanced.""" + with pytest.raises(InvalidParamError): + uploadcare.iterate_search_files(REQUEST, limit=-1) + + +def test_invalid_request_is_rejected_eagerly(uploadcare): + """The request itself is validated by the wrapper, not by the generator.""" + with patch.object(uploadcare.files_api, "search") as mocked_search: + with pytest.raises(ValidationError): + uploadcare.iterate_search_files({}) + + mocked_search.assert_not_called() + + +def test_invalid_request_is_rejected_even_with_limit_zero(uploadcare): + """`limit=0` short-circuits paging but must not skip validation.""" + with pytest.raises(ValidationError): + uploadcare.iterate_search_files({}, limit=0) + + +def test_request_is_validated_only_once(uploadcare): + """The generator receives a model, so pages do not re-validate.""" + pages = [ + _page(2, next_url=HOSTILE_NEXT, total=4), + _page(2, next_url=None, total=4, offset=2), + ] + + with _patched_search(uploadcare, pages) as mocked_search: + list(uploadcare.iterate_search_files(REQUEST, request_limit=2)) + + requests = [call.args[0] for call in mocked_search.call_args_list] + assert len(requests) == 2 + assert requests[0] is requests[1] + + +# --- undefined result order ------------------------------------------------- + + +def test_undefined_order_warning(uploadcare): + """A filter-only request without `sort` has an undefined order.""" + with _patched_search(uploadcare, [_page(1)]): + with pytest.warns(UserWarning, match="undefined"): + list(uploadcare.iterate_search_files({"tags": {"all": ["cat"]}})) + + +@pytest.mark.parametrize( + "request_", + [ + {"tags": {"all": ["cat"]}, "sort": ["-datetime_uploaded"]}, + {"query": "sunset"}, + {"phrase": {"original_filename": "sunset"}}, + ], +) +def test_no_warning_when_the_order_is_defined(uploadcare, request_): + """`sort`, or relevance from `query`/`phrase`, gives a defined order.""" + import warnings + + with _patched_search(uploadcare, [_page(1)]): + with warnings.catch_warnings(): + warnings.simplefilter("error") + list(uploadcare.iterate_search_files(request_)) diff --git a/tests/functional/ucare_cli/cassettes/test_cli_get_file_tags.yaml b/tests/functional/ucare_cli/cassettes/test_cli_get_file_tags.yaml new file mode 100644 index 00000000..d51c40bc --- /dev/null +++ b/tests/functional/ucare_cli/cassettes/test_cli_get_file_tags.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.uploadcare.com + method: GET + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '25' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/ucare_cli/cassettes/test_cli_search_files.yaml b/tests/functional/ucare_cli/cassettes/test_cli_search_files.yaml new file mode 100644 index 00000000..4228def9 --- /dev/null +++ b/tests/functional/ucare_cli/cassettes/test_cli_search_files.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '{"query": "sunset", "tags": {"any": ["cat"]}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: POST + uri: https://api.uploadcare.com/files/search/?limit=1 + response: + content: '{"next":null,"previous":null,"total":1,"per_page":1,"results":[{"uuid":"a55d6b25-d03c-4038-9838-6e06bb7df598","original_filename":"sunset-cat.jpg","size":1024,"mime_type":"image/jpeg","is_image":true,"is_ready":true,"tags":["cat"],"metadata":{},"highlight":{"original_filename":["sunset-cat.jpg"]}}]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/ucare_cli/cassettes/test_cli_set_file_tags.yaml b/tests/functional/ucare_cli/cassettes/test_cli_set_file_tags.yaml new file mode 100644 index 00000000..a026b5eb --- /dev/null +++ b/tests/functional/ucare_cli/cassettes/test_cli_set_file_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"tags": ["cat", "animal"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PUT + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal"],"added":["animal"],"deleted":[]}' + headers: + Connection: + - keep-alive + Content-Length: + - '56' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/ucare_cli/cassettes/test_cli_update_file_tags.yaml b/tests/functional/ucare_cli/cassettes/test_cli_update_file_tags.yaml new file mode 100644 index 00000000..43ea3065 --- /dev/null +++ b/tests/functional/ucare_cli/cassettes/test_cli_update_file_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"add": ["cute"], "delete": ["dog"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PATCH + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","cute"],"added":["cute"],"deleted":["dog"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '57' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/ucare_cli/helpers.py b/tests/functional/ucare_cli/helpers.py index 90062d7f..1e17c513 100644 --- a/tests/functional/ucare_cli/helpers.py +++ b/tests/functional/ucare_cli/helpers.py @@ -1,5 +1,12 @@ from pyuploadcare.ucare_cli.main import ucare_argparser -def arg_namespace(arguments_str): - return ucare_argparser().parse_args(arguments_str.split()) +def arg_namespace(arguments): + """Parse CLI arguments given as a string or as an already split list. + + A list is needed for values that contain whitespace or a leading dash. + """ + if isinstance(arguments, str): + arguments = arguments.split() + + return ucare_argparser().parse_args(arguments) diff --git a/tests/functional/ucare_cli/test_file_tags.py b/tests/functional/ucare_cli/test_file_tags.py new file mode 100644 index 00000000..03803769 --- /dev/null +++ b/tests/functional/ucare_cli/test_file_tags.py @@ -0,0 +1,86 @@ +import pytest +from tests.functional.ucare_cli.helpers import arg_namespace + +from pyuploadcare.exceptions import InvalidParamError +from pyuploadcare.ucare_cli.commands.get_file_tags import get_file_tags +from pyuploadcare.ucare_cli.commands.set_file_tags import set_file_tags +from pyuploadcare.ucare_cli.commands.update_file_tags import update_file_tags +from pyuploadcare.ucare_cli.main import main + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + + +@pytest.mark.vcr +def test_cli_get_file_tags(capsys, uploadcare): + get_file_tags(arg_namespace(f"get_file_tags {FILE_UUID}"), uploadcare) + captured = capsys.readouterr() + assert '"cat"' in captured.out + assert '"animal"' in captured.out + + +def test_cli_get_file_tags_by_cdn_url(capsys, uploadcare, vcr): + with vcr.use_cassette("test_cli_get_file_tags"): + get_file_tags( + arg_namespace(f"get_file_tags https://ucarecdn.com/{FILE_UUID}/"), + uploadcare, + ) + + captured = capsys.readouterr() + assert '"cat"' in captured.out + + +@pytest.mark.vcr +def test_cli_set_file_tags(capsys, uploadcare): + set_file_tags( + arg_namespace(f"set_file_tags {FILE_UUID} cat animal"), uploadcare + ) + captured = capsys.readouterr() + assert '"added"' in captured.out + assert '"deleted"' in captured.out + + +@pytest.mark.vcr +def test_cli_update_file_tags(capsys, uploadcare): + update_file_tags( + arg_namespace(f"update_file_tags {FILE_UUID} --add cute --delete dog"), + uploadcare, + ) + captured = capsys.readouterr() + assert '"cute"' in captured.out + + +def test_cli_update_file_tags_without_flags_raises(uploadcare): + with pytest.raises(InvalidParamError): + update_file_tags( + arg_namespace(f"update_file_tags {FILE_UUID}"), uploadcare + ) + + +def test_cli_update_file_tags_without_flags_prints_error(capsys): + """`main()` turns the error into a message instead of a traceback.""" + main( + arg_namespace( + "--pub_key demopublickey --secret demosecretkey " + f"update_file_tags {FILE_UUID}" + ), + config_file_names=(), + ) + captured = capsys.readouterr() + assert "ERROR:" in captured.out + + +def test_cli_set_file_tags_accepts_no_tags(): + """Zero tags is a deliberate "clear the tags" request.""" + parsed = arg_namespace(f"set_file_tags {FILE_UUID}") + assert parsed.tags == [] + + +def test_cli_upload_parses_tags(): + parsed = arg_namespace("upload sample.txt --tags cat animal") + assert parsed.tags == ["cat", "animal"] + + +def test_cli_upload_without_tags(): + parsed = arg_namespace("upload sample.txt") + assert parsed.tags is None diff --git a/tests/functional/ucare_cli/test_search_files.py b/tests/functional/ucare_cli/test_search_files.py new file mode 100644 index 00000000..af5c83da --- /dev/null +++ b/tests/functional/ucare_cli/test_search_files.py @@ -0,0 +1,104 @@ +from datetime import datetime + +import pytest +from tests.functional.ucare_cli.helpers import arg_namespace + +from pyuploadcare.exceptions import InvalidParamError +from pyuploadcare.ucare_cli.commands.search_files import ( + _build_request, + search_files, +) +from pyuploadcare.ucare_cli.main import main + + +@pytest.mark.vcr +def test_cli_search_files(capsys, uploadcare): + search_files( + arg_namespace("search_files --query sunset --tags_any cat --limit 1"), + uploadcare, + ) + captured = capsys.readouterr() + + assert '"total": 1' in captured.out + assert '"sunset-cat.jpg"' in captured.out + assert '"sunset-cat.jpg"' in captured.out + + +def test_cli_search_files_without_conditions_prints_error(capsys): + """A `ValidationError` must not surface as a traceback.""" + main( + arg_namespace( + "--pub_key demopublickey --secret demosecretkey search_files" + ), + config_file_names=(), + ) + captured = capsys.readouterr() + + assert "ERROR:" in captured.out + + +def test_cli_search_files_without_conditions_raises(uploadcare): + with pytest.raises(InvalidParamError): + search_files(arg_namespace("search_files"), uploadcare) + + +def test_cli_builds_nested_conditions(): + parsed = arg_namespace( + "search_files" + " --phrase_original_filename sunset" + " --exact_detected_mime_type image/jpeg image/png" + " --size_gt 1000 --size_lte 5000" + " --uploaded_gte 2024-01-02" + " --tags_any cat dog --tags_none old" + " --is_image true" + " --fuzziness" + ) + + assert _build_request(parsed) == { + "fuzziness": True, + "is_image": True, + "phrase": {"original_filename": "sunset"}, + "exact": {"detected_mime_type": ["image/jpeg", "image/png"]}, + "size": {"gt": 1000, "lte": 5000}, + "datetime_uploaded": {"gte": datetime(2024, 1, 2)}, + "tags": {"any": ["cat", "dog"], "none": ["old"]}, + } + + +def test_cli_omits_unset_conditions(): + parsed = arg_namespace("search_files --query sunset") + + assert _build_request(parsed) == {"query": "sunset"} + + +def test_cli_sort_accepts_repeated_keys(): + parsed = arg_namespace("search_files --query sunset --sort=-score") + parsed_two = arg_namespace( + "search_files --query sunset --sort=-score --sort=size" + ) + + assert parsed.sort == ["-score"] + assert parsed_two.sort == ["-score", "size"] + + +def test_cli_sort_rejects_unknown_key(): + with pytest.raises(SystemExit): + arg_namespace("search_files --query sunset --sort=relevance") + + +@pytest.mark.parametrize("value", ["true", "false", "TRUE", " False "]) +def test_cli_is_image_accepts_booleans(value): + parsed = arg_namespace(["search_files", "--is_image", value]) + assert parsed.is_image is (value.strip().lower() == "true") + + +@pytest.mark.parametrize("value", ["yes", "1", "maybe", ""]) +def test_cli_is_image_rejects_other_values(value): + """Unlike `bool_or_none`, unknown input must not become `None`.""" + with pytest.raises(SystemExit): + arg_namespace(["search_files", "--is_image", value]) + + +def test_cli_uploaded_bound_rejects_unparsable_datetime(): + with pytest.raises(SystemExit): + arg_namespace("search_files --uploaded_gte not-a-date") diff --git a/tests/integration/test_file_search.py b/tests/integration/test_file_search.py new file mode 100644 index 00000000..d6f38356 --- /dev/null +++ b/tests/integration/test_file_search.py @@ -0,0 +1,247 @@ +"""File search against the live REST API. + +https://uploadcare.com/docs/file-search/ + +Search indexing is asynchronous, so tests that need to find a file they just +uploaded poll through ``wait_until_searchable``. Everything that only needs +*some* indexed content searches over what the project already holds. +""" + +import pytest + +from pyuploadcare.api.entities import FileSearchInfo +from pyuploadcare.api.search_entities import ( + FileSearchRequest, + SearchExact, + SearchPhrase, + SizeRange, + TagsFilter, +) + +from .utils import ( + search_term, + unique_tag, + upload_image_file, + wait_until_searchable, +) + + +# Filter-only requests have an undefined order, so anything paginated or +# order-sensitive carries an explicit sort. +BY_NEWEST = ["-datetime_uploaded"] + + +@pytest.fixture(scope="module") +def indexed_file(uploadcare): + """A file already in the project, and therefore already searchable.""" + files = list(uploadcare.list_files(limit=1, removed=False)) + + if not files: + pytest.skip("the project has no files to search") + + return files[0] + + +@pytest.fixture(scope="module") +def indexed_term(indexed_file): + term = search_term(indexed_file.info.get("original_filename")) + + if not term: + pytest.skip("no filename with a 4+ character term to search for") + + return term + + +def test_search_response_shape(uploadcare): + """The envelope and every result parse into the SDK's models.""" + response = uploadcare.search_files( + FileSearchRequest(is_image=True, sort=BY_NEWEST), limit=3 + ) + + assert isinstance(response.total, int) + assert response.per_page == 3 + assert len(response.results) <= 3 + + for result in response.results: + assert isinstance(result, FileSearchInfo) + assert isinstance(result.tags, list) + assert result.uuid is not None + + +def test_query_returns_a_highlight(uploadcare, indexed_term): + response = uploadcare.search_files( + FileSearchRequest(query=indexed_term), limit=3 + ) + + assert response.total >= 1 + + highlighted = [ + value + for result in response.results + if result.highlight and result.highlight.original_filename + for value in result.highlight.original_filename + ] + + assert highlighted, "expected a highlight for a full text match" + assert any("" in value for value in highlighted) + + +def test_phrase_on_original_filename(uploadcare, indexed_term): + response = uploadcare.search_files( + FileSearchRequest(phrase=SearchPhrase(original_filename=indexed_term)), + limit=3, + ) + + assert response.total >= 1 + + +def test_filter_only_search_highlights_nothing(uploadcare): + """No highlight field is populated without a full text condition. + + The API reference says `highlight` is "absent for filter-only matches", + but the live API sends an empty object instead, so the model parses it + into a `SearchHighlight` whose every field is `None`. Either shape is + handled; what matters is that no field carries a value. + """ + response = uploadcare.search_files( + FileSearchRequest(is_image=True, sort=BY_NEWEST), limit=3 + ) + + if not response.results: + pytest.skip("the project has no images to search") + + for result in response.results: + if result.highlight is None: + continue + + assert result.highlight.original_filename is None + assert result.highlight.detected_mime_type is None + assert result.highlight.metadata is None + + +def test_exact_uuid(uploadcare, indexed_file): + response = uploadcare.search_files( + FileSearchRequest(exact=SearchExact(uuid=[indexed_file.uuid])) + ) + + assert response.total == 1 + assert str(response.results[0].uuid) == indexed_file.uuid + + +def test_tags_filter_finds_a_tagged_file(uploadcare): + """Tags attached on upload become searchable once indexed.""" + tag = unique_tag() + file_ = upload_image_file(uploadcare, tags=[tag]) + + try: + response = wait_until_searchable( + uploadcare, + FileSearchRequest(tags=TagsFilter(all_=[tag]), sort=BY_NEWEST), + ) + + assert response.total == 1 + + result = response.results[0] + assert str(result.uuid) == file_.uuid + assert result.tags == [tag] + finally: + file_.delete() + + +def test_tags_none_filter_excludes_a_tagged_file(uploadcare): + tag = unique_tag() + file_ = upload_image_file(uploadcare, tags=[tag]) + + try: + wait_until_searchable( + uploadcare, + FileSearchRequest(tags=TagsFilter(all_=[tag]), sort=BY_NEWEST), + ) + + response = uploadcare.search_files( + FileSearchRequest(tags=TagsFilter(none_=[tag]), sort=BY_NEWEST), + limit=5, + ) + + found = {str(result.uuid) for result in response.results} + assert file_.uuid not in found + finally: + file_.delete() + + +def test_size_range(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(size=SizeRange(gt=1), sort=BY_NEWEST), limit=3 + ) + + assert all(result.size > 1 for result in response.results) + + +def test_sort_descending_by_size(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(is_image=True, sort=["-size"]), limit=5 + ) + + sizes = [result.size for result in response.results] + assert sizes == sorted(sizes, reverse=True) + + +def test_include_appdata(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(is_image=True, sort=BY_NEWEST), + limit=1, + include_appdata=True, + ) + + if not response.results: + pytest.skip("the project has no images to search") + + assert response.results[0].appdata is not None + + +def test_appdata_is_absent_without_the_flag(uploadcare): + response = uploadcare.search_files( + FileSearchRequest(is_image=True, sort=BY_NEWEST), limit=1 + ) + + if not response.results: + pytest.skip("the project has no images to search") + + assert response.results[0].appdata is None + + +def test_pagination_yields_no_duplicates(uploadcare): + """Each page is requested at a locally computed offset.""" + uuids = [ + str(result.uuid) + for result in uploadcare.iterate_search_files( + FileSearchRequest(is_image=True, sort=BY_NEWEST), + limit=5, + request_limit=2, + ) + ] + + assert len(uuids) <= 5 + assert len(uuids) == len(set(uuids)) + + +def test_pagination_matches_a_single_page(uploadcare): + request = FileSearchRequest(is_image=True, sort=BY_NEWEST) + + page = uploadcare.search_files(request, limit=4) + iterated = list( + uploadcare.iterate_search_files(request, limit=4, request_limit=2) + ) + + assert [str(result.uuid) for result in iterated] == [ + str(result.uuid) for result in page.results + ] + + +def test_dict_request(uploadcare): + """A plain dict in the SDK's shape works like the model.""" + response = uploadcare.search_files( + {"is_image": True, "sort": ["-datetime_uploaded"]}, limit=1 + ) + + assert isinstance(response.total, int) diff --git a/tests/integration/test_file_tags.py b/tests/integration/test_file_tags.py new file mode 100644 index 00000000..e40f2f1c --- /dev/null +++ b/tests/integration/test_file_tags.py @@ -0,0 +1,121 @@ +"""File tags against the live REST API. + +https://uploadcare.com/docs/file-tags/ +""" + +import pytest + +from pyuploadcare.exceptions import TagValidationError + +from .utils import upload_image_file + + +@pytest.fixture +def tagged_file(uploadcare): + """A freshly uploaded file carrying tags that need normalizing.""" + file_ = upload_image_file(uploadcare, tags=[" Cat ", "CAT", "animal"]) + yield file_ + file_.delete() + + +@pytest.fixture +def untagged_file(uploadcare): + file_ = upload_image_file(uploadcare) + yield file_ + file_.delete() + + +def test_upload_attaches_tags(uploadcare, tagged_file): + """Tags sent as a form field on upload are stored, normalized.""" + assert uploadcare.tags_api.get(tagged_file.uuid) == ["cat", "animal"] + + +def test_upload_without_tags_stores_none(uploadcare, untagged_file): + assert uploadcare.tags_api.get(untagged_file.uuid) == [] + + +def test_file_info_reports_tags(tagged_file): + tagged_file.update_info() + assert tagged_file.info["tags"] == ["cat", "animal"] + + +def test_file_info_reports_an_empty_list_without_tags(untagged_file): + untagged_file.update_info() + assert untagged_file.info["tags"] == [] + + +def test_list_files_reports_tags(uploadcare, tagged_file): + """`GET /files/` includes the field, not just `GET /files/{uuid}/`.""" + for file_ in uploadcare.list_files(limit=3, removed=False): + assert isinstance(file_.info["tags"], list) + + +def test_get_tags(tagged_file): + assert tagged_file.get_tags() == ["cat", "animal"] + + +def test_tags_property_after_direct_upload_loads_the_info(tagged_file): + """Regression: a direct upload caches nothing, so `tags` fetches it. + + The upload response carries no tags, but the direct upload path leaves + the info cache unset, so reading the property loads the stored tags. + """ + assert tagged_file._info_cache is None + assert tagged_file.tags == ["cat", "animal"] + + +def test_set_tags(tagged_file): + response = tagged_file.set_tags(["cat", "cute", "pet"]) + + assert response.tags == ["cat", "cute", "pet"] + assert sorted(response.added) == ["cute", "pet"] + assert response.deleted == ["animal"] + assert tagged_file.tags == ["cat", "cute", "pet"] + + +def test_set_tags_with_an_empty_list_clears_them(uploadcare, tagged_file): + response = tagged_file.set_tags([]) + + assert response.tags == [] + assert sorted(response.deleted) == ["animal", "cat"] + assert uploadcare.tags_api.get(tagged_file.uuid) == [] + + +def test_update_tags(tagged_file): + response = tagged_file.update_tags(add=["cute"], delete=["animal"]) + + assert response.added == ["cute"] + assert response.deleted == ["animal"] + assert sorted(response.tags) == ["cat", "cute"] + + +def test_update_tags_ignores_absent_deletions(tagged_file): + """Deleting a tag the file does not have is not an error.""" + response = tagged_file.update_tags(delete=["never-was-there"]) + + assert response.deleted == [] + assert response.tags == ["cat", "animal"] + + +def test_update_tags_skips_already_present_additions(tagged_file): + response = tagged_file.update_tags(add=["cat"]) + + assert response.added == [] + assert response.tags == ["cat", "animal"] + + +def test_update_tags_without_arguments_returns_the_current_state( + uploadcare, tagged_file +): + """The endpoint documents both fields as optional, so `{}` is valid.""" + response = uploadcare.tags_api.update(tagged_file.uuid) + + assert response.tags == ["cat", "animal"] + assert response.added == [] + assert response.deleted == [] + + +def test_invalid_tags_are_rejected_locally(uploadcare, untagged_file): + """Validation happens before the request, so the API never sees these.""" + with pytest.raises(TagValidationError): + uploadcare.tags_api.replace(untagged_file.uuid, ["not valid"]) diff --git a/tests/integration/ucare_cli/test_file_tags.py b/tests/integration/ucare_cli/test_file_tags.py new file mode 100644 index 00000000..8627deb9 --- /dev/null +++ b/tests/integration/ucare_cli/test_file_tags.py @@ -0,0 +1,117 @@ +"""The tags and search CLI commands against the live REST API.""" + +import json + +import pytest +from tests.functional.ucare_cli.helpers import arg_namespace +from tests.integration.utils import IMAGE_PATH, upload_image_file + +from pyuploadcare.ucare_cli.main import main + + +@pytest.fixture +def keys(uploadcare): + """Credentials as argv tokens. They take precedence over config files.""" + return [ + "--pub_key", + uploadcare.public_key, + "--secret", + uploadcare.secret_key, + ] + + +@pytest.fixture +def tagged_file(uploadcare): + file_ = upload_image_file(uploadcare, tags=["cat", "animal"]) + yield file_ + file_.delete() + + +def _output(capsys): + return json.loads(capsys.readouterr().out) + + +def test_get_file_tags(capsys, keys, tagged_file): + main(arg_namespace([*keys, "get_file_tags", tagged_file.uuid])) + + assert _output(capsys) == ["cat", "animal"] + + +def test_get_file_tags_by_cdn_url(capsys, keys, tagged_file): + main(arg_namespace([*keys, "get_file_tags", tagged_file.cdn_url])) + + assert _output(capsys) == ["cat", "animal"] + + +def test_set_file_tags(capsys, keys, tagged_file): + main( + arg_namespace( + [*keys, "set_file_tags", tagged_file.uuid, "cat", "cute"] + ) + ) + + response = _output(capsys) + assert response["tags"] == ["cat", "cute"] + assert response["added"] == ["cute"] + assert response["deleted"] == ["animal"] + + +def test_set_file_tags_without_tags_clears_them(capsys, keys, tagged_file): + main(arg_namespace([*keys, "set_file_tags", tagged_file.uuid])) + + assert _output(capsys)["tags"] == [] + + +def test_update_file_tags(capsys, keys, tagged_file): + main( + arg_namespace( + [ + *keys, + "update_file_tags", + tagged_file.uuid, + "--add", + "cute", + "--delete", + "animal", + ] + ) + ) + + response = _output(capsys) + assert response["added"] == ["cute"] + assert response["deleted"] == ["animal"] + + +def test_update_file_tags_without_flags_reports_an_error( + capsys, keys, tagged_file +): + main(arg_namespace([*keys, "update_file_tags", tagged_file.uuid])) + + assert "ERROR:" in capsys.readouterr().out + + +def test_upload_with_tags(capsys, keys, uploadcare): + main( + arg_namespace( + [*keys, "upload", str(IMAGE_PATH), "--tags", "cli-test", "--info"] + ) + ) + + file_ = uploadcare.file(_output(capsys)["uuid"]) + + try: + assert uploadcare.tags_api.get(file_.uuid) == ["cli-test"] + finally: + file_.delete() + + +def test_search_files(capsys, keys): + main( + arg_namespace( + [*keys, "search_files", "--is_image", "true", "--limit", "1"] + ) + ) + + response = _output(capsys) + assert isinstance(response["total"], int) + assert len(response["results"]) <= 1 diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 312b1c15..c453cbae 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -1,9 +1,76 @@ # coding: utf-8 from __future__ import unicode_literals +import random +import re +import time +from pathlib import Path from tempfile import NamedTemporaryFile +ASSETS_PATH = Path(__file__).parent / "assets" +IMAGE_PATH = ASSETS_PATH / "img.png" + +# A freshly uploaded file takes about ten seconds to become searchable. +SEARCH_INDEXING_TIMEOUT = 90 +SEARCH_INDEXING_INTERVAL = 2 + +# Tags allow Latin letters, digits, `-`, `_` and `.` only. +TERM_PATTERN = re.compile(r"[A-Za-z0-9]{4,}") + + +def upload_image_file(uploadcare, tags=None): + """Upload the test image, optionally with tags. + + Not stored, so the project's autostore setting cannot leave it behind. + """ + with open(IMAGE_PATH, "rb") as fh: + return uploadcare.upload(fh, store=False, tags=tags) + + +def unique_tag(prefix="pyuploadcare-test"): + """A tag no other test run will collide on.""" + return f"{prefix}-{random.randint(10 ** 9, 10 ** 10)}" + + +def search_term(filename): + """A term from `filename` long enough for a full text condition. + + ``query`` and ``phrase`` values must be at least 4 characters, so a + filename without such a run cannot be searched for. Returns ``None`` then. + """ + match = TERM_PATTERN.search(filename or "") + return match.group(0) if match else None + + +def wait_until_searchable( + uploadcare, + request, + limit=5, + timeout=SEARCH_INDEXING_TIMEOUT, + interval=SEARCH_INDEXING_INTERVAL, +): + """Poll search until `request` returns results, then return the response. + + Search indexing is asynchronous, so a file is not findable the moment it + is uploaded. + """ + deadline = time.monotonic() + timeout + + while True: + response = uploadcare.search_files(request, limit=limit) + + if response.results: + return response + + if time.monotonic() >= deadline: + raise AssertionError( + f"search returned nothing within {timeout}s for {request!r}" + ) + + time.sleep(interval) + + def upload_tmp_txt_file(uploadcare, content=""): tmp_txt_file = NamedTemporaryFile(mode="wb", delete=False) tmp_txt_file.write(content.encode("utf-8"))