From e469966e3789354c0607aff2c79e225964b4b804 Mon Sep 17 00:00:00 2001 From: loookashow Date: Tue, 4 Aug 2026 10:51:05 +0200 Subject: [PATCH 1/4] Add support for file tags Expose the REST API v0.7 file tags endpoints in the SDK. - TagsAPI (uploadcare.tags_api) with get/replace/update, covering GET, PUT and PATCH on /files/{uuid}/tags/ - File: tags property plus get_tags(), set_tags() and update_tags() - tags argument for Uploadcare.upload(), upload_files() and multipart_upload(), sent as the comma-separated tags form field - tags in FileInfo - ucare get_file_tags, set_file_tags and update_file_tags commands, and a --tags option for ucare upload - TagValidationError for tags exceeding the API limits Tags are normalized before validation, the way the API normalizes them, so values it would accept after its own normalization are not rejected locally. update() with no arguments sends an empty body, since the endpoint documents both fields as optional; requiring at least one of --add/--delete is a CLI concern instead. The 50-tags-per-file limit does not apply to PATCH delete, which is a candidate list where absent tags are ignored. Uploads from url do not support tags upstream, so passing them raises InvalidParamError rather than dropping them silently. FileInfo.model_dump(), and therefore File.info, now always contains a tags key: None for responses that do not report tags, [] for files without any. --- HISTORY.md | 23 +++ README.md | 32 +++ docs/core_api.rst | 65 ++++++ pyproject.toml | 2 +- pyuploadcare/__init__.py | 2 +- pyuploadcare/api/__init__.py | 1 + pyuploadcare/api/api.py | 102 +++++++++ pyuploadcare/api/entities.py | 3 + pyuploadcare/api/responses.py | 13 ++ pyuploadcare/api/tags.py | 96 +++++++++ pyuploadcare/client.py | 28 ++- pyuploadcare/exceptions.py | 6 + pyuploadcare/resources/file.py | 66 ++++++ .../ucare_cli/commands/get_file_tags.py | 14 ++ .../ucare_cli/commands/set_file_tags.py | 23 +++ .../ucare_cli/commands/update_file_tags.py | 37 ++++ pyuploadcare/ucare_cli/commands/upload.py | 10 +- pyuploadcare/ucare_cli/main.py | 6 + .../cassettes/test_get_empty_file_tags.yaml | 30 +++ .../api/cassettes/test_get_file_tags.yaml | 30 +++ .../api/cassettes/test_replace_file_tags.yaml | 32 +++ .../api/cassettes/test_update_file_tags.yaml | 32 +++ tests/functional/api/test_tags_api.py | 47 +++++ .../functional/api/test_tags_api_requests.py | 193 ++++++++++++++++++ tests/functional/api/test_tags_validation.py | 126 ++++++++++++ .../api/test_upload_api_with_tags.py | 102 +++++++++ .../cassettes/test_file_get_tags.yaml | 30 +++ .../cassettes/test_file_set_tags.yaml | 32 +++ .../cassettes/test_file_update_tags.yaml | 32 +++ tests/functional/resources/test_file_tags.py | 91 +++++++++ .../resources/test_file_tags_propagation.py | 106 ++++++++++ .../cassettes/test_cli_get_file_tags.yaml | 30 +++ .../cassettes/test_cli_set_file_tags.yaml | 32 +++ .../cassettes/test_cli_update_file_tags.yaml | 32 +++ tests/functional/ucare_cli/test_file_tags.py | 86 ++++++++ 35 files changed, 1588 insertions(+), 4 deletions(-) create mode 100644 pyuploadcare/api/tags.py create mode 100644 pyuploadcare/ucare_cli/commands/get_file_tags.py create mode 100644 pyuploadcare/ucare_cli/commands/set_file_tags.py create mode 100644 pyuploadcare/ucare_cli/commands/update_file_tags.py create mode 100644 tests/functional/api/cassettes/test_get_empty_file_tags.yaml create mode 100644 tests/functional/api/cassettes/test_get_file_tags.yaml create mode 100644 tests/functional/api/cassettes/test_replace_file_tags.yaml create mode 100644 tests/functional/api/cassettes/test_update_file_tags.yaml create mode 100644 tests/functional/api/test_tags_api.py create mode 100644 tests/functional/api/test_tags_api_requests.py create mode 100644 tests/functional/api/test_tags_validation.py create mode 100644 tests/functional/api/test_upload_api_with_tags.py create mode 100644 tests/functional/resources/cassettes/test_file_get_tags.yaml create mode 100644 tests/functional/resources/cassettes/test_file_set_tags.yaml create mode 100644 tests/functional/resources/cassettes/test_file_update_tags.yaml create mode 100644 tests/functional/resources/test_file_tags.py create mode 100644 tests/functional/resources/test_file_tags_propagation.py create mode 100644 tests/functional/ucare_cli/cassettes/test_cli_get_file_tags.yaml create mode 100644 tests/functional/ucare_cli/cassettes/test_cli_set_file_tags.yaml create mode 100644 tests/functional/ucare_cli/cassettes/test_cli_update_file_tags.yaml create mode 100644 tests/functional/ucare_cli/test_file_tags.py diff --git a/HISTORY.md b/HISTORY.md index e972aeb6..6a57dc4f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.3.0](https://github.com/uploadcare/pyuploadcare/compare/v6.2.1...v6.3.0) - 2026-08-03 + +### Added + +- Support for [file tags](https://uploadcare.com/docs/file-tags/): + - `TagsAPI` (`uploadcare.tags_api`) with `get()`, `replace()` and `update()` methods, covering + `GET`, `PUT` and `PATCH` on `/files/{uuid}/tags/`. + - For `File`: a `tags` property plus `get_tags()`, `set_tags()` and `update_tags()` methods. + - A `tags` argument for `Uploadcare.upload()`, `Uploadcare.upload_files()` and + `Uploadcare.multipart_upload()`. Uploads from url do not support tags and raise + `InvalidParamError` instead of silently dropping them. + - `tags` in `FileInfo`. + - New `ucare` commands `get_file_tags`, `set_file_tags` and `update_file_tags`, and a `--tags` + option for `ucare upload`. + - `TagValidationError`, raised when tags exceed the API limits (50 tags per file, 100 characters + per tag, Latin letters, digits, `-`, `_` and `.` only). + +### Changed + +- `FileInfo.model_dump()`, and therefore `File.info`, now always contains a `tags` key. It is + `None` for responses that do not report tags, such as upload responses, and `[]` for files + without tags. + ## [6.2.1](https://github.com/uploadcare/pyuploadcare/compare/v6.2.0...v6.2.1) - 2025-09-02 ### Added diff --git a/README.md b/README.md index 6f841158..9779f738 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Build file handling in minutes. Upload or accept user-generated content, store, - [Requirements](#requirements) - [Usage](#usage) - [Basic usage](#basic-usage) + - [File tags](#file-tags) - [Django integration](#django-integration) - [Testing](#testing) - [Demo app](#demo-app) @@ -163,6 +164,37 @@ print(ucare_file.cdn_url) # https://demo.ucarecd.net/640fe4b7-7352-42ca-8d87-0e There’s a lot more to uncover. For more information please refer to the [documentation](#documentation). +#### File tags + +Files can carry a list of [tags](https://uploadcare.com/docs/file-tags/) you can later filter on: + +```python +file_ = uploadcare.file("640fe4b7-7352-42ca-8d87-0e4387957157") + +file_.get_tags() # ['cat', 'animal'] +file_.set_tags(["cat", "animal", "cute"]) # replace all tags +file_.update_tags(add=["pet"], delete=["animal"]) # add and delete atomically +file_.set_tags([]) # clear all tags +``` + +`set_tags()` and `update_tags()` return the resulting tag list along with what changed: + +```python +response = file_.update_tags(add=["pet"], delete=["animal"]) +print(response.tags, response.added, response.deleted) +# ['cat', 'cute', 'pet'] ['pet'] ['animal'] +``` + +Tags can also be attached at upload time: + +```python +with open("sample-file.jpeg", "rb") as file_object: + ucare_file = uploadcare.upload(file_object, tags=["cat", "cute"]) +``` + +Tags are lowercased, trimmed and deduplicated. A file can hold up to 50 tags of up to 100 +characters each, made of Latin letters, digits, `-`, `_` and `.`. + ### Django integration Let's add [File Uploader](https://uploadcare.com/docs/file-uploader/) to an existing Django project. diff --git a/docs/core_api.rst b/docs/core_api.rst index 0ff62b91..81c2c4d0 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -199,6 +199,71 @@ 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 + +Note that ``File.tags`` is ``None`` when the cached file info does not report tags at all — upload +responses do not include them — and ``[]`` when the file genuinely has no tags. + +Replace all tags of a file. Passing an empty list clears them:: + + response = file.set_tags(['cat', 'animal', 'cute']) + print(response.tags, response.added, response.deleted) + + file.set_tags([]) + +Add and/or delete tags atomically:: + + response = file.update_tags(add=['pet'], delete=['animal']) + +Both methods return a response carrying the resulting ``tags`` plus the ``added`` and ``deleted`` +tags. The same operations are available on the API directly:: + + uploadcare.tags_api.get(file_id) + uploadcare.tags_api.replace(file_id, ['cat', 'animal']) + uploadcare.tags_api.update(file_id, add=['pet'], delete=['animal']) + +Tags are normalized before being sent: they are lowercased, trimmed, deduplicated keeping the +first occurrence, and empty ones are dropped. A file can hold up to 50 tags of up to 100 +characters each, containing Latin letters, digits, ``-``, ``_`` and ``.`` only. Anything else +raises ``TagValidationError`` before a request is made. + +Tags are available from the CLI as well:: + + ucare get_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 + ucare set_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 cat animal + ucare update_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 --add pet --delete animal + ucare upload file.txt --tags cat cute + +.. _file tags documentation: https://uploadcare.com/docs/file-tags/ + + Video conversion ---------------- 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..ef83bee3 100644 --- a/pyuploadcare/__init__.py +++ b/pyuploadcare/__init__.py @@ -1,5 +1,5 @@ # isort: skip_file -__version__ = "6.2.1" +__version__ = "6.3.0" from pyuploadcare.resources.file import File # noqa: F401 from pyuploadcare.resources.file_group import FileGroup # noqa: F401 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..4c460650 100644 --- a/pyuploadcare/api/api.py +++ b/pyuploadcare/api/api.py @@ -28,12 +28,14 @@ from pyuploadcare.exceptions import ( APIError, DuplicateFileError, + InvalidParamError, InvalidRequestError, WebhookIsNotUnique, ) from .entities import UUIDEntity from .metadata import validate_meta_key, validate_meta_value, validate_metadata +from .tags import validate_tags from .utils import flatten_dict @@ -271,6 +273,20 @@ def generate_secure_signature(secret: str, expire: int): secret.encode("utf-8"), str(expire).encode("utf-8"), hashlib.sha256 ).hexdigest() + @staticmethod + def _set_tags(data: Dict[str, Any], tags: Optional[Iterable[str]]) -> None: + """Add the comma-separated `tags` form field to `data`, if any. + + The field is omitted entirely when there is nothing to send. + """ + if tags is None: + return + + validated_tags = validate_tags(tags) + + if validated_tags: + data["tags"] = ",".join(validated_tags) + def upload( # noqa: C901 self, files: RequestFiles, @@ -280,6 +296,7 @@ def upload( # noqa: C901 secret_key: Optional[str] = None, store: Optional[str] = "auto", expire: Optional[int] = None, + tags: Optional[Iterable[str]] = None, ) -> Dict[str, Any]: data = {} @@ -292,6 +309,8 @@ def upload( # noqa: C901 validate_metadata(common_metadata) data.update(flatten_dict(common_metadata)) + self._set_tags(data, tags) + data["UPLOADCARE_PUB_KEY"] = public_key if secure_upload: @@ -318,6 +337,7 @@ def start_multipart_upload( store: Optional[str] = None, secure_upload: bool = False, expire: Optional[int] = None, + tags: Optional[Iterable[str]] = None, ): data = { "filename": file_name, @@ -333,6 +353,8 @@ def start_multipart_upload( validate_metadata(metadata) data.update(flatten_dict(metadata)) + self._set_tags(data, tags) + if secure_upload: expire = ( (int(time()) + self.signed_uploads_ttl) @@ -515,6 +537,86 @@ def get_key(self, file_uuid: Union[UUID, str], mkey: str) -> str: return cast(str, response) +class TagsAPI(API): + """File tags. + + https://uploadcare.com/docs/file-tags/ + """ + + resource_type = "files" + response_classes = { + "get": responses.GetFileTagsResponse, + "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..caaa3914 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 diff --git a/pyuploadcare/api/responses.py b/pyuploadcare/api/responses.py index 0bc1b540..9333ad69 100644 --- a/pyuploadcare/api/responses.py +++ b/pyuploadcare/api/responses.py @@ -79,6 +79,19 @@ class GetAllMetadataResponse(RootModel, Entity): root: MetadataDict +class GetFileTagsResponse(Response): + # https://uploadcare.com/docs/api/rest/file-tags/get-tags/ + tags: List[str] + + +class UpdateFileTagsResponse(Response): + # https://uploadcare.com/docs/api/rest/file-tags/put-tags/ + # https://uploadcare.com/docs/api/rest/file-tags/patch-tags/ + tags: List[str] + added: List[str] + deleted: List[str] + + class AddonResponseResult(Entity): pass 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/client.py b/pyuploadcare/client.py index 82340863..b417eed9 100644 --- a/pyuploadcare/client.py +++ b/pyuploadcare/client.py @@ -24,6 +24,7 @@ GroupsAPI, MetadataAPI, ProjectAPI, + TagsAPI, UploadAPI, VideoConvertAPI, WebhooksAPI, @@ -180,6 +181,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 +252,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 +303,12 @@ 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. Note that the upload response + does not report tags back, so ``File.tags`` stays ``None`` + until ``File.update_info()`` is called. Returns: ``File`` instance @@ -308,6 +317,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 +339,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 +356,7 @@ def upload( # noqa: C901 size=size, callback=callback, metadata=metadata, + tags=tags, ) return file @@ -362,6 +378,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 +396,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 +424,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 +437,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 +459,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 +480,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"] 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/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..fd75d828 100644 --- a/pyuploadcare/ucare_cli/main.py +++ b/pyuploadcare/ucare_cli/main.py @@ -17,12 +17,15 @@ delete_files, delete_webhook, get_file, + get_file_tags, get_project, list_files, list_groups, list_webhooks, + set_file_tags, store_files, sync, + update_file_tags, update_webhook, upload, upload_from_url, @@ -58,6 +61,9 @@ 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) # 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_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_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..927de536 --- /dev/null +++ b/tests/functional/resources/test_file_tags_propagation.py @@ -0,0 +1,106 @@ +"""``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_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/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_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/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 From 610d3f2118a0f8932a725bd0bc41d6a40e25413f Mon Sep 17 00:00:00 2001 From: loookashow Date: Tue, 4 Aug 2026 17:27:04 +0200 Subject: [PATCH 2/4] Add support for file search Expose the REST API v0.7 POST /files/search/ endpoint in the SDK. - Uploadcare.search_files(), returning one typed page, and Uploadcare.iterate_search_files(), which walks pages - FilesAPI.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 works too - FileSearchResponse with next, previous, total, per_page and results, each result a FileSearchInfo carrying a SearchHighlight - ucare search_files command Requests are validated locally against the documented API constraints before any request is made, so a malformed search fails fast with a clear message. Paging never requests the response's next URL: it is an absolute, server-supplied URL, and the REST client attaches credentials to whatever URL it is given. next is used only as a has-more signal and the offset is computed locally, clamped so that no request exceeds the 1000-result window. total is not used as a stop condition because the API documents it as approximate for large result sets. iterate_search_files() warns when asked to page through a filter-only request without sort, whose result order the API leaves undefined. --- HISTORY.md | 17 +- README.md | 59 +++ docs/core_api.rst | 115 ++++++ pyuploadcare/__init__.py | 9 + pyuploadcare/api/api.py | 72 +++- pyuploadcare/api/entities.py | 25 ++ pyuploadcare/api/responses.py | 6 + pyuploadcare/api/search_entities.py | 292 ++++++++++++++ pyuploadcare/api/utils.py | 38 ++ pyuploadcare/client.py | 215 ++++++++++- pyuploadcare/ucare_cli/commands/helpers.py | 17 + .../ucare_cli/commands/search_files.py | 157 ++++++++ pyuploadcare/ucare_cli/main.py | 2 + .../api/cassettes/test_search_files.yaml | 31 ++ .../test_search_files_empty_result.yaml | 32 ++ .../test_search_files_with_appdata.yaml | 30 ++ tests/functional/api/test_search_api.py | 111 ++++++ .../api/test_search_api_requests.py | 184 +++++++++ tests/functional/api/test_search_request.py | 361 ++++++++++++++++++ .../resources/test_search_pagination.py | 336 ++++++++++++++++ .../cassettes/test_cli_search_files.yaml | 30 ++ tests/functional/ucare_cli/helpers.py | 11 +- .../functional/ucare_cli/test_search_files.py | 104 +++++ 23 files changed, 2248 insertions(+), 6 deletions(-) create mode 100644 pyuploadcare/api/search_entities.py create mode 100644 pyuploadcare/ucare_cli/commands/search_files.py create mode 100644 tests/functional/api/cassettes/test_search_files.yaml create mode 100644 tests/functional/api/cassettes/test_search_files_empty_result.yaml create mode 100644 tests/functional/api/cassettes/test_search_files_with_appdata.yaml create mode 100644 tests/functional/api/test_search_api.py create mode 100644 tests/functional/api/test_search_api_requests.py create mode 100644 tests/functional/api/test_search_request.py create mode 100644 tests/functional/resources/test_search_pagination.py create mode 100644 tests/functional/ucare_cli/cassettes/test_cli_search_files.yaml create mode 100644 tests/functional/ucare_cli/test_search_files.py diff --git a/HISTORY.md b/HISTORY.md index 6a57dc4f..0a35b55f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [6.3.0](https://github.com/uploadcare/pyuploadcare/compare/v6.2.1...v6.3.0) - 2026-08-03 +## [6.3.0](https://github.com/uploadcare/pyuploadcare/compare/v6.2.1...v6.3.0) - 2026-08-04 ### Added @@ -22,6 +22,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). option for `ucare upload`. - `TagValidationError`, raised when tags exceed the API limits (50 tags per file, 100 characters per tag, Latin letters, digits, `-`, `_` and `.` only). +- Support for [file search](https://uploadcare.com/docs/file-search/): + - `Uploadcare.search_files()`, returning one page of results, and + `Uploadcare.iterate_search_files()`, which walks pages. As with the other list APIs, `limit` + is the total number of results to yield and `request_limit` is the page size. + - `FilesAPI.search()` for `POST /files/search/`. + - Typed requests: `FileSearchRequest` with `query`, `phrase`, `exact`, `datetime_uploaded`, + `size`, `is_image` and `tags` conditions plus the `fuzziness` and `sort` modifiers, built from + `SearchPhrase`, `SearchExact`, `DatetimeRange`, `SizeRange`, `TagsFilter` and `SearchSort`. + A plain dict in the same shape is accepted too. Requests are validated locally against the + documented API constraints before a request is made. + - `FileSearchResponse` with `next`, `previous`, `total`, `per_page` and `results`, where each + result is a `FileSearchInfo` — file info plus a `SearchHighlight`. + - `iterate_search_files()` warns when asked to page through a filter-only request without + `sort`, whose result order the API leaves undefined. + - A new `ucare search_files` command. ### Changed diff --git a/README.md b/README.md index 9779f738..ea84fcff 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Build file handling in minutes. Upload or accept user-generated content, store, - [Usage](#usage) - [Basic usage](#basic-usage) - [File tags](#file-tags) + - [File search](#file-search) - [Django integration](#django-integration) - [Testing](#testing) - [Demo app](#demo-app) @@ -195,6 +196,64 @@ with open("sample-file.jpeg", "rb") as file_object: Tags are lowercased, trimmed and deduplicated. A file can hold up to 50 tags of up to 100 characters each, made of Latin letters, digits, `-`, `_` and `.`. +#### File search + +A single [search](https://uploadcare.com/docs/file-search/) request can combine full-text search, +exact matching, range filters and tag filters. At least one condition is required: + +```python +from pyuploadcare import FileSearchRequest, SizeRange, TagsFilter + +response = uploadcare.search_files( + FileSearchRequest( + query="sunset", + tags=TagsFilter(all_=["cat"], none_=["draft"]), + size=SizeRange(gt=1024), + is_image=True, + fuzziness=True, + sort=["-score", "size"], + ), + limit=50, +) + +print(response.total, response.per_page) + +for file_info in response.results: + print(file_info.original_filename, file_info.tags) + if file_info.highlight: + print(file_info.highlight.original_filename) # ['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 81c2c4d0..4f7b9d70 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -264,6 +264,121 @@ Tags are available from the CLI as well:: .. _file tags documentation: https://uploadcare.com/docs/file-tags/ +Searching files +--------------- + +A single search request (`file search documentation`_) can combine full-text search, exact +matching, range filters and tag filters. At least one condition is required: ``query``, +``phrase``, ``exact``, ``datetime_uploaded``, ``size``, ``is_image`` or ``tags``. ``fuzziness`` +and ``sort`` are modifiers and do not count as conditions:: + + from pyuploadcare import ( + DatetimeRange, + FileSearchRequest, + SearchExact, + SearchPhrase, + SizeRange, + TagsFilter, + ) + + response = uploadcare.search_files( + FileSearchRequest( + query='sunset', + tags=TagsFilter(all_=['cat'], none_=['draft']), + size=SizeRange(gt=1024, lte=10 * 1024 * 1024), + is_image=True, + fuzziness=True, + sort=['-score', 'size'], + ), + limit=50, + ) + + print(response.total, response.per_page, response.next) + + for file_info in response.results: + print(file_info.original_filename, file_info.tags) + +``query`` searches across all searchable fields and must be at least 4 characters. ``phrase`` +performs an ordered full-text match on a specific field, and ``exact`` matches values exactly:: + + request = FileSearchRequest( + phrase=SearchPhrase(original_filename='holiday photo'), + exact=SearchExact(detected_mime_type=['image/jpeg', 'image/png']), + datetime_uploaded=DatetimeRange(gte=datetime(2024, 1, 1)), + ) + +A field cannot appear in both ``phrase`` and ``exact`` in the same request. + +``exact`` can also match metadata values. In the SDK this is a nested mapping, which is +serialized into the ``metadata[]`` 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_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. It is +present only for fields matched by a full-text condition and absent for filter-only matches:: + + 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/pyuploadcare/__init__.py b/pyuploadcare/__init__.py index ef83bee3..a44f6d3a 100644 --- a/pyuploadcare/__init__.py +++ b/pyuploadcare/__init__.py @@ -6,4 +6,13 @@ from pyuploadcare.resources.file_list import FileList # noqa: F401 from pyuploadcare.resources.group_list import GroupList # noqa: F401 from pyuploadcare.api.entities import Webhook, ProjectInfo # noqa: F401 +from pyuploadcare.api.search_entities import ( # noqa: F401 + DatetimeRange, + FileSearchRequest, + SearchExact, + SearchPhrase, + SearchSort, + SizeRange, + TagsFilter, +) from pyuploadcare.client import Uploadcare # noqa: F401 diff --git a/pyuploadcare/api/api.py b/pyuploadcare/api/api.py index 4c460650..4258f0c5 100644 --- a/pyuploadcare/api/api.py +++ b/pyuploadcare/api/api.py @@ -35,13 +35,21 @@ from .entities import UUIDEntity from .metadata import validate_meta_key, validate_meta_value, validate_metadata +from .search_entities import FileSearchRequest from .tags import validate_tags -from .utils import flatten_dict +from .utils import flatten_dict, require_optional_int, require_range logger = logging.getLogger("pyuploadcare") +# File search pagination limits. +# https://uploadcare.com/docs/api/rest/file/search-files/ +SEARCH_DEFAULT_LIMIT = 20 # the server default when `limit` is not sent +SEARCH_MAX_LIMIT = 100 +SEARCH_MAX_WINDOW = 1000 # `offset` + `limit` must not exceed this + + class FilesAPI(API, ListCountMixin, RetrieveMixin, DeleteWithResponseMixin): resource_type = "files" response_classes = { @@ -55,8 +63,70 @@ class FilesAPI(API, ListCountMixin, RetrieveMixin, DeleteWithResponseMixin): "batch_delete": responses.BatchFileOperationResponse, "local_copy": responses.CreateLocalCopyResponse, "remote_copy": responses.CreateRemoteCopyResponse, + "search": responses.FileSearchResponse, } + def search( + self, + request: Union[FileSearchRequest, Dict[str, Any]], + limit: Optional[int] = None, + offset: Optional[int] = None, + include_appdata: bool = False, + ) -> responses.FileSearchResponse: + """Search files, returning a single page of results. + + https://uploadcare.com/docs/file-search/ + + Args: + - request: a ``FileSearchRequest`` or a dict in the same shape. + A dict uses the SDK's shape for ``exact.metadata``, i.e. + ``{"exact": {"metadata": {"color": ["red"]}}}``, not the wire + shape ``{"exact": {"metadata[color]": [...]}}``. + - limit: results per page, 1 to 100. The server defaults to 20. + - offset: how many results to skip. ``offset + limit`` must not + exceed 1000. + - include_appdata: embed application data in every result. + """ + search_request = ( + request + if isinstance(request, FileSearchRequest) + else FileSearchRequest.model_validate(request) + ) + + require_optional_int("limit", limit) + require_optional_int("offset", offset) + require_range("limit", limit, minimum=1, maximum=SEARCH_MAX_LIMIT) + require_range("offset", offset, minimum=0) + + effective_limit = SEARCH_DEFAULT_LIMIT if limit is None else limit + effective_offset = 0 if offset is None else offset + + if effective_offset + effective_limit > SEARCH_MAX_WINDOW: + raise InvalidParamError( + "`offset` + `limit` must not exceed " + f"{SEARCH_MAX_WINDOW}, got " + f"{effective_offset} + {effective_limit}. " + "Narrow the query instead of paging deeper" + ) + + query_parameters: Dict[str, Any] = {} + if limit is not None: + query_parameters["limit"] = limit + if offset is not None: + query_parameters["offset"] = offset + if include_appdata: + query_parameters["include"] = "appdata" + + url = self._build_url( + suffix="search", query_parameters=query_parameters + ) + response_class = self._get_response_class("search") + json_response = self._client.post( + url, json=search_request.to_payload() + ).json() + response = self._parse_response(json_response, response_class) + return cast(responses.FileSearchResponse, response) + def store(self, file_uuid: Union[UUID, str]) -> entities.FileInfo: url = self._build_url(file_uuid, suffix="storage") response_class = self._get_response_class("store") diff --git a/pyuploadcare/api/entities.py b/pyuploadcare/api/entities.py index caaa3914..fd2151c1 100644 --- a/pyuploadcare/api/entities.py +++ b/pyuploadcare/api/entities.py @@ -240,6 +240,31 @@ class FileInfo(UUIDEntity): appdata: Optional[ApplicationDataSet] = None +class SearchHighlight(Entity): + """Matched tokens wrapped in ```` tags by the search backend. + + Present only for fields matched by a full-text condition (``query`` or + ``phrase``), absent for filter-only matches. + + 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 9333ad69..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 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/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 b417eed9..d5636e9c 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, @@ -29,10 +31,23 @@ 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, @@ -779,6 +794,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/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/main.py b/pyuploadcare/ucare_cli/main.py index fd75d828..8d163035 100644 --- a/pyuploadcare/ucare_cli/main.py +++ b/pyuploadcare/ucare_cli/main.py @@ -22,6 +22,7 @@ list_files, list_groups, list_webhooks, + search_files, set_file_tags, store_files, sync, @@ -64,6 +65,7 @@ def ucare_argparser(): 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_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/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/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_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/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_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") From 2a1167fc794a561486ba138998e50c3ae9b28e6f Mon Sep 17 00:00:00 2001 From: loookashow Date: Wed, 5 Aug 2026 09:55:27 +0200 Subject: [PATCH 3/4] Fix File.tags documentation for the two upload paths Verified against the live API: after a direct upload nothing is cached, so reading File.tags fetches the file info and returns the stored tags. The None case applies to multipart uploads, where the upload response is cached as the file info and carries no tags key. Pin both paths with tests. --- docs/core_api.rst | 16 +++++-- pyuploadcare/api/entities.py | 6 ++- pyuploadcare/client.py | 10 +++-- .../resources/test_file_tags_propagation.py | 43 +++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/core_api.rst b/docs/core_api.rst index 4f7b9d70..ae2f68cf 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -228,8 +228,12 @@ Or, if the file info is already loaded, without an extra request:: tags = file.tags -Note that ``File.tags`` is ``None`` when the cached file info does not report tags at all — upload -responses do not include them — and ``[]`` when the file genuinely has no 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:: @@ -329,6 +333,9 @@ Without ``sort``, results are ordered by relevance. A filter-only request has no ``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. @@ -354,8 +361,9 @@ 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. It is -present only for fields matched by a full-text condition and absent for filter-only matches:: +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: diff --git a/pyuploadcare/api/entities.py b/pyuploadcare/api/entities.py index fd2151c1..60799b2f 100644 --- a/pyuploadcare/api/entities.py +++ b/pyuploadcare/api/entities.py @@ -243,8 +243,10 @@ class FileInfo(UUIDEntity): class SearchHighlight(Entity): """Matched tokens wrapped in ```` tags by the search backend. - Present only for fields matched by a full-text condition (``query`` or - ``phrase``), absent for filter-only matches. + 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 diff --git a/pyuploadcare/client.py b/pyuploadcare/client.py index d5636e9c..5c67c7b8 100644 --- a/pyuploadcare/client.py +++ b/pyuploadcare/client.py @@ -321,9 +321,13 @@ def upload( # noqa: C901 - tags (Optional[Iterable[str]]): Optional `tags `_ to attach to the uploaded file. Not supported for uploads from url; use - ``File.set_tags()`` for those. Note that the upload response - does not report tags back, so ``File.tags`` stays ``None`` - until ``File.update_info()`` is called. + ``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 diff --git a/tests/functional/resources/test_file_tags_propagation.py b/tests/functional/resources/test_file_tags_propagation.py index 927de536..9915555a 100644 --- a/tests/functional/resources/test_file_tags_propagation.py +++ b/tests/functional/resources/test_file_tags_propagation.py @@ -90,6 +90,49 @@ def test_upload_uses_multipart_path_for_big_files(uploadcare, memo_file): 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: From f0c19015a83f1446d020e6c8caee7d25d163f587 Mon Sep 17 00:00:00 2001 From: loookashow Date: Wed, 5 Aug 2026 09:56:07 +0200 Subject: [PATCH 4/4] Add integration tests for file tags and search Cover both features against the live REST API: tags CRUD, tags on upload, tags in file info and listings, search conditions, modifiers, highlight, appdata and pagination, plus the new CLI commands. Tests create and delete their own files, and upload a PNG rather than a text file so they work on projects that restrict uploadable types. Search indexing is asynchronous, taking on the order of ten seconds, so looking for a freshly uploaded file polls through wait_until_searchable while the rest search over content the project already holds. Document the delay. Correct the highlight documentation: the API reference says the field is absent for filter-only matches, but the live API sends an empty object. --- tests/integration/test_file_search.py | 247 ++++++++++++++++++ tests/integration/test_file_tags.py | 121 +++++++++ tests/integration/ucare_cli/test_file_tags.py | 117 +++++++++ tests/integration/utils.py | 67 +++++ 4 files changed, 552 insertions(+) create mode 100644 tests/integration/test_file_search.py create mode 100644 tests/integration/test_file_tags.py create mode 100644 tests/integration/ucare_cli/test_file_tags.py 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"))