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..857f6a2c 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -199,6 +199,75 @@ But you should better use a special attribute of `File.info`:: file_metadata = file.info["metadata"] +File tags +--------- + +Each file can carry a list of tags (`file tags documentation`_), which you can later use as a +filter when searching files. + +Tags may be initially set while uploading:: + + with open('file.txt', 'rb') as file_object: + ucare_file: File = uploadcare.upload(file_object, tags=['cat', 'cute']) + +While uploading multiple files at once, the tags are applied to every file in the collection:: + + file1 = open('file1.txt', 'rb') + file2 = open('file2.txt', 'rb') + ucare_files: List[File] = uploadcare.upload_files([file1, file2], tags=['cat']) + # don't forget to close the files, of course + +Uploads from URL do not support tags. Use ``File.set_tags()`` after such an upload instead. + +Read the current tags of a file:: + + file = uploadcare.file('740e1b8c-1ad8-4324-b7ec-112c79d8eac2') + tags: List[str] = file.get_tags() + +Or, if the file info is already loaded, without an extra request:: + + tags = file.tags + +``File.tags`` reads from the cached file info, fetching it first if nothing is cached yet. It is +``[]`` when the file has no tags, and ``None`` only when the cached info came from a response that +does not report tags at all. In practice that happens after a multipart upload, whose response is +cached as the file info and carries no ``tags`` — call ``File.update_info()`` or +``File.get_tags()`` there. A direct upload caches nothing, so reading ``File.tags`` after it +fetches the info and returns the stored tags. + +Replace all tags of a file. Passing an empty list clears them:: + + response = file.set_tags(['cat', 'animal', 'cute']) + print(response.tags, response.added, response.deleted) + + file.set_tags([]) + +Add and/or delete tags atomically:: + + response = file.update_tags(add=['pet'], delete=['animal']) + +Both methods return a response carrying the resulting ``tags`` plus the ``added`` and ``deleted`` +tags. The same operations are available on the API directly:: + + uploadcare.tags_api.get(file_id) + uploadcare.tags_api.replace(file_id, ['cat', 'animal']) + uploadcare.tags_api.update(file_id, add=['pet'], delete=['animal']) + +Tags are normalized before being sent: they are lowercased, trimmed, deduplicated keeping the +first occurrence, and empty ones are dropped. A file can hold up to 50 tags of up to 100 +characters each, containing Latin letters, digits, ``-``, ``_`` and ``.`` only. Anything else +raises ``TagValidationError`` before a request is made. + +Tags are available from the CLI as well:: + + ucare get_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 + ucare set_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 cat animal + ucare update_file_tags 740e1b8c-1ad8-4324-b7ec-112c79d8eac2 --add pet --delete animal + ucare upload file.txt --tags cat cute + +.. _file tags documentation: https://uploadcare.com/docs/file-tags/ + + Video conversion ---------------- 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..86e94ca3 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, + "set": responses.UpdateFileTagsResponse, + "update": responses.UpdateFileTagsResponse, + } + + @staticmethod + def _canonical_uuid(file_uuid: Union[UUID, str]) -> str: + """Return a canonical UUID string, rejecting anything else. + + ``API._build_url`` joins the identifier with ``urljoin``, so a value + like ``"//example.com/x"`` or an absolute URL would replace the + configured API origin on an authenticated request. + """ + try: + return str(UUID(str(file_uuid))) + except (AttributeError, TypeError, ValueError): + raise InvalidParamError(f"Invalid UUID: {file_uuid!s}") + + def _tags_url(self, file_uuid: Union[UUID, str]) -> str: + return self._build_url(self._canonical_uuid(file_uuid), suffix="tags") + + def get(self, file_uuid: Union[UUID, str]) -> List[str]: + """Return the tags of a file, an empty list if it has none.""" + url = self._tags_url(file_uuid) + response_class = self._get_response_class("get") + json_response = self._client.get(url).json() + response = self._parse_response(json_response, response_class) + return cast(responses.GetFileTagsResponse, response).tags + + def set( + self, file_uuid: Union[UUID, str], tags: Iterable[str] + ) -> responses.UpdateFileTagsResponse: + """Replace all tags of a file. + + Passing an empty collection clears the tags. + """ + url = self._tags_url(file_uuid) + data = {"tags": validate_tags(tags)} + response_class = self._get_response_class("set") + json_response = self._client.put(url, json=data).json() + response = self._parse_response(json_response, response_class) + return cast(responses.UpdateFileTagsResponse, response) + + def update( + self, + file_uuid: Union[UUID, str], + add: Optional[Iterable[str]] = None, + delete: Optional[Iterable[str]] = None, + ) -> responses.UpdateFileTagsResponse: + """Add and/or delete tags of a file atomically. + + Both arguments are optional, matching the endpoint: calling this + without them sends an empty request and returns the current state. + """ + data: Dict[str, List[str]] = {} + + if add is not None: + data["add"] = validate_tags(add) + + if delete is not None: + # No count limit here: `delete` is a list of candidates and tags + # that are not present are ignored, so it may legitimately be + # longer than the per-file storage limit. + data["delete"] = validate_tags(delete, max_count=None) + + url = self._tags_url(file_uuid) + response_class = self._get_response_class("update") + json_response = self._client.patch(url, json=data).json() + response = self._parse_response(json_response, response_class) + return cast(responses.UpdateFileTagsResponse, response) + + class AddonsAPI(API): resource_type = "addons" request_type: Type[AddonExecutionGeneralRequestData] = ( 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..68870e24 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,16 @@ def upload( # noqa: C901 Used for multipart uploading. - callback (Optional[Callable[[UploadProgress], Any]]): Optional callback accepting ``UploadProgress`` to track uploading progress. + - tags (Optional[Iterable[str]]): Optional + `tags `_ to attach to + the uploaded file. Not supported for uploads from URL; use + ``File.set_tags()`` for those. + Upload responses do not report tags back. After a direct + upload nothing is cached, so reading ``File.tags`` fetches the + file info and returns the stored tags. After a multipart + upload the info from the upload response is cached, and that + response has no tags, so ``File.tags`` is ``None`` until + ``File.update_info()`` is called. Returns: ``File`` instance @@ -308,6 +321,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 +343,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 +360,7 @@ def upload( # noqa: C901 size=size, callback=callback, metadata=metadata, + tags=tags, ) return file @@ -362,6 +382,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 +400,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 +428,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 +441,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 +463,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 +484,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..53b640be 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,73 @@ 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: + """Replace the file's entire tag set with ``tags``. + + Any current tag not in ``tags`` is removed; an empty list clears them + all. The response reports the resulting ``tags`` plus the ``added`` + and ``deleted`` deltas the server computed:: + + >>> 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.set(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: + """Update tags. Pass `add` and `delete` lists to be added and deleted respectively. + + >>> 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..e4c555b3 --- /dev/null +++ b/tests/functional/api/test_tags_api.py @@ -0,0 +1,45 @@ +"""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.set(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..f0c32233 --- /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.set(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.set(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.set(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.set(FILE_UUID, ["not valid"]) + + mocked_put.assert_not_called() diff --git a/tests/functional/api/test_tags_validation.py b/tests/functional/api/test_tags_validation.py new file mode 100644 index 00000000..57f3d17f --- /dev/null +++ b/tests/functional/api/test_tags_validation.py @@ -0,0 +1,126 @@ +import pytest + +from pyuploadcare.api.tags import ( + MAX_TAGS_PER_FILE, + TAG_MAX_LEN, + normalize_tag, + normalize_tags, + validate_tag, + validate_tags, +) +from pyuploadcare.exceptions import TagValidationError + + +def test_normalize_tag_lowercases_and_strips(): + assert normalize_tag(" Cat ") == "cat" + + +def test_normalize_tags_lowercases(): + assert normalize_tags(["cat", "Cat", "CAT"]) == ["cat"] + + +def test_normalize_tags_strips_whitespace(): + assert normalize_tags([" cat ", "\tanimal\n"]) == ["cat", "animal"] + + +def test_normalize_tags_discards_empty_strings(): + assert normalize_tags(["cat", "", " ", "animal"]) == ["cat", "animal"] + + +def test_normalize_tags_preserves_first_seen_order(): + assert normalize_tags(["dog", "cat", "Dog", "animal"]) == [ + "dog", + "cat", + "animal", + ] + + +def test_normalize_tags_accepts_any_iterable(): + assert normalize_tags(("cat", "animal")) == ["cat", "animal"] + assert normalize_tags(tag for tag in ["cat", "animal"]) == [ + "cat", + "animal", + ] + + +def test_normalize_tags_rejects_a_bare_string(): + with pytest.raises(TagValidationError): + normalize_tags("cat,animal") + + +def test_normalize_tags_rejects_non_string_items(): + with pytest.raises(TagValidationError): + normalize_tags(["cat", 42]) # type: ignore[list-item] + + +@pytest.mark.parametrize( + "tag", ["cat", "animal-2", "under_score", "with.dot", "MiXeD", "0"] +) +def test_validate_tag_accepts_allowed_characters(tag): + validate_tag(tag) + + +@pytest.mark.parametrize( + "tag", + [ + "with space", + "with+plus", + "with@at", + "with/slash", + "with,comma", + "кот", + "with:colon", + "", + "cat\n", + "\ncat", + "cat\t", + ], +) +def test_validate_tag_rejects_disallowed_characters(tag): + with pytest.raises(TagValidationError): + validate_tag(tag) + + +def test_validate_tag_rejects_non_string(): + with pytest.raises(TagValidationError): + validate_tag(42) # type: ignore[arg-type] + + +def test_validate_tags_accepts_max_length_tag(): + tag = "a" * TAG_MAX_LEN + assert validate_tags([tag]) == [tag] + + +def test_validate_tags_rejects_too_long_tag(): + with pytest.raises(TagValidationError): + validate_tags(["a" * (TAG_MAX_LEN + 1)]) + + +def test_validate_tags_returns_normalized_list(): + assert validate_tags([" Cat ", "CAT", "animal"]) == ["cat", "animal"] + + +def test_validate_tags_accepts_max_amount_of_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE)] + assert validate_tags(tags) == tags + + +def test_validate_tags_rejects_too_many_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 1)] + with pytest.raises(TagValidationError): + validate_tags(tags) + + +def test_validate_tags_counts_tags_after_deduplication(): + """51 tags collapsing into 50 unique ones is within the limit.""" + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE)] + assert validate_tags([*tags, "TAG0"]) == tags + + +def test_validate_tags_without_max_count_allows_more_tags(): + tags = [f"tag{index}" for index in range(MAX_TAGS_PER_FILE + 10)] + assert validate_tags(tags, max_count=None) == tags + + +def test_validate_tags_accepts_empty_collection(): + assert validate_tags([]) == [] diff --git a/tests/functional/api/test_upload_api_with_tags.py b/tests/functional/api/test_upload_api_with_tags.py new file mode 100644 index 00000000..b699897f --- /dev/null +++ b/tests/functional/api/test_upload_api_with_tags.py @@ -0,0 +1,102 @@ +"""Request shape assertions for tags in the Upload API. + +Tags are sent as a comma-separated ``tags`` form field, which VCR's default +matcher does not check, so a mocked client is used instead of a cassette. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from pyuploadcare.exceptions import TagValidationError + + +def _upload_response(payload=None): + response = MagicMock() + response.json.return_value = payload or {} + return response + + +@pytest.fixture +def upload_api(uploadcare): + return uploadcare.upload_api + + +def _sent_data(mocked_post): + return mocked_post.call_args.kwargs["data"] + + +def test_upload_sends_comma_separated_tags(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=["cat", "animal"]) + + assert _sent_data(mocked_post)["tags"] == "cat,animal" + + +def test_upload_normalizes_tags(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=[" Cat ", "CAT", "dog"]) + + assert _sent_data(mocked_post)["tags"] == "cat,dog" + + +def test_upload_omits_tags_when_not_given(upload_api, small_file): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}) + + assert "tags" not in _sent_data(mocked_post) + + +@pytest.mark.parametrize("tags", [[], ["", " "]]) +def test_upload_omits_tags_when_they_normalize_to_empty( + upload_api, small_file, tags +): + with patch.object( + upload_api._client, "post", return_value=_upload_response() + ) as mocked_post: + with open(small_file.name, "rb") as fh: + upload_api.upload({"file.txt": fh}, tags=tags) + + assert "tags" not in _sent_data(mocked_post) + + +def test_upload_rejects_invalid_tags(upload_api, small_file): + with patch.object(upload_api._client, "post") as mocked_post: + with open(small_file.name, "rb") as fh: + with pytest.raises(TagValidationError): + upload_api.upload({"file.txt": fh}, tags=["not valid"]) + + mocked_post.assert_not_called() + + +def test_start_multipart_upload_sends_comma_separated_tags(upload_api): + with patch.object( + upload_api._client, + "post", + return_value=_upload_response({"uuid": "x", "parts": []}), + ) as mocked_post: + upload_api.start_multipart_upload( + "file.txt", 100, "text/plain", tags=["cat", "animal"] + ) + + assert _sent_data(mocked_post)["tags"] == "cat,animal" + + +def test_start_multipart_upload_omits_tags_when_not_given(upload_api): + with patch.object( + upload_api._client, + "post", + return_value=_upload_response({"uuid": "x", "parts": []}), + ) as mocked_post: + upload_api.start_multipart_upload("file.txt", 100, "text/plain") + + assert "tags" not in _sent_data(mocked_post) diff --git a/tests/functional/resources/cassettes/test_file_get_tags.yaml b/tests/functional/resources/cassettes/test_file_get_tags.yaml new file mode 100644 index 00000000..d51c40bc --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_get_tags.yaml @@ -0,0 +1,30 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.uploadcare.com + method: GET + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '25' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/cassettes/test_file_set_tags.yaml b/tests/functional/resources/cassettes/test_file_set_tags.yaml new file mode 100644 index 00000000..1fa362a4 --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_set_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"tags": ["cat", "cute"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PUT + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","cute"],"added":["cute"],"deleted":["animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '60' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/cassettes/test_file_update_tags.yaml b/tests/functional/resources/cassettes/test_file_update_tags.yaml new file mode 100644 index 00000000..d6fc2f1b --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_update_tags.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"add": ["cute"], "delete": ["animal"]}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - api.uploadcare.com + method: PATCH + uri: https://api.uploadcare.com/files/a55d6b25-d03c-4038-9838-6e06bb7df598/tags/ + response: + content: '{"tags":["cat","cute"],"added":["cute"],"deleted":["animal"]}' + headers: + Connection: + - keep-alive + Content-Length: + - '60' + Content-Type: + - application/vnd.uploadcare-v0.7+json + Server: + - nginx + Vary: + - Accept + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/test_file_tags.py b/tests/functional/resources/test_file_tags.py new file mode 100644 index 00000000..017caf05 --- /dev/null +++ b/tests/functional/resources/test_file_tags.py @@ -0,0 +1,91 @@ +import pytest + +from pyuploadcare.api.entities import FileInfo + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + +FILE_INFO_WITH_TAGS = { + "uuid": FILE_UUID, + "original_filename": "sample.jpg", + "tags": ["cat", "animal"], +} + + +def test_file_info_parses_tags(): + file_info = FileInfo.model_validate(FILE_INFO_WITH_TAGS) + assert file_info.tags == ["cat", "animal"] + + +def test_file_info_parses_empty_tags(): + file_info = FileInfo.model_validate({"uuid": FILE_UUID, "tags": []}) + assert file_info.tags == [] + + +def test_file_info_without_tags_dumps_none(): + """Endpoints that do not report tags leave the field as ``None``. + + Regression guard: ``model_dump()`` gained a new ``tags`` key, which + consumers of ``File.info`` will now see. + """ + file_info = FileInfo.model_validate({"uuid": FILE_UUID}) + assert file_info.tags is None + assert file_info.model_dump()["tags"] is None + + +def test_file_tags_property_reads_from_info(uploadcare): + file_ = uploadcare.file(FILE_UUID, FILE_INFO_WITH_TAGS) + assert file_.tags == ["cat", "animal"] + + +@pytest.mark.vcr +def test_file_get_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + assert file_.get_tags() == ["cat", "animal"] + + +def test_file_get_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, {"uuid": FILE_UUID, "tags": []}) + + with vcr.use_cassette("test_file_get_tags"): + assert file_.get_tags() == ["cat", "animal"] + + assert file_.info["tags"] == ["cat", "animal"] + + +@pytest.mark.vcr +def test_file_set_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + response = file_.set_tags(["cat", "cute"]) + + assert response.tags == ["cat", "cute"] + assert response.added == ["cute"] + assert response.deleted == ["animal"] + + +def test_file_set_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, dict(FILE_INFO_WITH_TAGS)) + + with vcr.use_cassette("test_file_set_tags"): + file_.set_tags(["cat", "cute"]) + + assert file_.info["tags"] == ["cat", "cute"] + + +@pytest.mark.vcr +def test_file_update_tags(uploadcare): + file_ = uploadcare.file(FILE_UUID) + response = file_.update_tags(add=["cute"], delete=["animal"]) + + assert response.tags == ["cat", "cute"] + assert response.added == ["cute"] + assert response.deleted == ["animal"] + + +def test_file_update_tags_refreshes_cached_info(uploadcare, vcr): + file_ = uploadcare.file(FILE_UUID, dict(FILE_INFO_WITH_TAGS)) + + with vcr.use_cassette("test_file_update_tags"): + file_.update_tags(add=["cute"], delete=["animal"]) + + assert file_.info["tags"] == ["cat", "cute"] diff --git a/tests/functional/resources/test_file_tags_propagation.py b/tests/functional/resources/test_file_tags_propagation.py new file mode 100644 index 00000000..9915555a --- /dev/null +++ b/tests/functional/resources/test_file_tags_propagation.py @@ -0,0 +1,149 @@ +"""``tags`` must reach the Upload API through every public upload wrapper.""" + +from unittest.mock import patch + +import pytest + +from pyuploadcare.exceptions import InvalidParamError + + +TAGS = ["cat", "animal"] +UPLOADED_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + + +def test_upload_files_forwards_tags(uploadcare, small_file): + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload_files([fh], tags=TAGS) + + assert mocked_upload.call_args.kwargs["tags"] == TAGS + + +def test_upload_files_omits_tags_when_not_given(uploadcare, small_file): + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload_files([fh]) + + assert mocked_upload.call_args.kwargs["tags"] is None + + +def test_direct_upload_forwards_tags(uploadcare, small_file): + """Files below ``multipart_min_file_size`` take the direct upload path.""" + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ) as mocked_upload: + with open(small_file.name, "rb") as fh: + uploadcare.upload(fh, tags=TAGS) + + assert mocked_upload.call_args.kwargs["tags"] == TAGS + + +def _patched_multipart(uploadcare): + """Patch the three Upload API calls a multipart upload makes.""" + return ( + patch.object( + uploadcare.upload_api, + "start_multipart_upload", + return_value={ + "uuid": UPLOADED_UUID, + "parts": ["https://s3.example/part-1"], + }, + ), + patch.object(uploadcare.upload_api, "multipart_upload_chunk"), + patch.object( + uploadcare.upload_api, + "multipart_complete", + return_value={"uuid": UPLOADED_UUID}, + ), + ) + + +def test_multipart_upload_forwards_tags(uploadcare, memo_file): + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with start as mocked_start, chunk, complete: + uploadcare.multipart_upload(stream, size=size, tags=TAGS) + + assert mocked_start.call_args.kwargs["tags"] == TAGS + + +def test_upload_uses_multipart_path_for_big_files(uploadcare, memo_file): + """``upload()`` must forward tags on the multipart branch too.""" + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with patch.object(uploadcare, "multipart_min_file_size", 1): + with start as mocked_start, chunk, complete: + uploadcare.upload(stream, size=size, tags=TAGS) + + assert mocked_start.call_args.kwargs["tags"] == TAGS + + +def test_file_tags_after_direct_upload_fetches_info(uploadcare, small_file): + """A direct upload caches nothing, so `File.tags` fetches the info. + + Upload responses never report tags, but the direct path leaves + `_info_cache` unset, so reading `tags` goes to `GET /files/{uuid}/`. + """ + with patch.object( + uploadcare.upload_api, + "upload", + return_value={"sample1.txt": UPLOADED_UUID}, + ): + with open(small_file.name, "rb") as fh: + file_ = uploadcare.upload(fh, tags=TAGS) + + assert file_._info_cache is None + + with patch.object(uploadcare.files_api, "retrieve") as mocked_retrieve: + mocked_retrieve.return_value.model_dump.return_value = { + "uuid": UPLOADED_UUID, + "tags": TAGS, + } + assert file_.tags == TAGS + + mocked_retrieve.assert_called_once() + + +def test_file_tags_after_multipart_upload_is_none(uploadcare, memo_file): + """A multipart upload caches its own response, which has no tags. + + So `File.tags` reports `None` until the info is refreshed. + """ + stream, size = memo_file + start, chunk, complete = _patched_multipart(uploadcare) + + with start, chunk, complete: + file_ = uploadcare.multipart_upload(stream, size=size, tags=TAGS) + + # Cached from the upload response, which carries no `tags` key. + assert file_._info_cache is not None + assert "tags" not in file_._info_cache + assert file_.tags is None + + +def test_upload_from_url_rejects_tags(uploadcare): + """`/from_url/` does not support tags, so they must not be dropped.""" + with patch.object(uploadcare, "upload_from_url_sync") as mocked_upload: + with pytest.raises(InvalidParamError): + uploadcare.upload("https://example.com/file.jpg", tags=TAGS) + + mocked_upload.assert_not_called() + + +def test_upload_from_url_without_tags_still_works(uploadcare): + with patch.object(uploadcare, "upload_from_url_sync") as mocked_upload: + uploadcare.upload("https://example.com/file.jpg") + + mocked_upload.assert_called_once() diff --git a/tests/functional/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/helpers.py b/tests/functional/ucare_cli/helpers.py index 90062d7f..1e17c513 100644 --- a/tests/functional/ucare_cli/helpers.py +++ b/tests/functional/ucare_cli/helpers.py @@ -1,5 +1,12 @@ from pyuploadcare.ucare_cli.main import ucare_argparser -def arg_namespace(arguments_str): - return ucare_argparser().parse_args(arguments_str.split()) +def arg_namespace(arguments): + """Parse CLI arguments given as a string or as an already split list. + + A list is needed for values that contain whitespace or a leading dash. + """ + if isinstance(arguments, str): + arguments = arguments.split() + + return ucare_argparser().parse_args(arguments) diff --git a/tests/functional/ucare_cli/test_file_tags.py b/tests/functional/ucare_cli/test_file_tags.py new file mode 100644 index 00000000..03803769 --- /dev/null +++ b/tests/functional/ucare_cli/test_file_tags.py @@ -0,0 +1,86 @@ +import pytest +from tests.functional.ucare_cli.helpers import arg_namespace + +from pyuploadcare.exceptions import InvalidParamError +from pyuploadcare.ucare_cli.commands.get_file_tags import get_file_tags +from pyuploadcare.ucare_cli.commands.set_file_tags import set_file_tags +from pyuploadcare.ucare_cli.commands.update_file_tags import update_file_tags +from pyuploadcare.ucare_cli.main import main + + +FILE_UUID = "a55d6b25-d03c-4038-9838-6e06bb7df598" + + +@pytest.mark.vcr +def test_cli_get_file_tags(capsys, uploadcare): + get_file_tags(arg_namespace(f"get_file_tags {FILE_UUID}"), uploadcare) + captured = capsys.readouterr() + assert '"cat"' in captured.out + assert '"animal"' in captured.out + + +def test_cli_get_file_tags_by_cdn_url(capsys, uploadcare, vcr): + with vcr.use_cassette("test_cli_get_file_tags"): + get_file_tags( + arg_namespace(f"get_file_tags https://ucarecdn.com/{FILE_UUID}/"), + uploadcare, + ) + + captured = capsys.readouterr() + assert '"cat"' in captured.out + + +@pytest.mark.vcr +def test_cli_set_file_tags(capsys, uploadcare): + set_file_tags( + arg_namespace(f"set_file_tags {FILE_UUID} cat animal"), uploadcare + ) + captured = capsys.readouterr() + assert '"added"' in captured.out + assert '"deleted"' in captured.out + + +@pytest.mark.vcr +def test_cli_update_file_tags(capsys, uploadcare): + update_file_tags( + arg_namespace(f"update_file_tags {FILE_UUID} --add cute --delete dog"), + uploadcare, + ) + captured = capsys.readouterr() + assert '"cute"' in captured.out + + +def test_cli_update_file_tags_without_flags_raises(uploadcare): + with pytest.raises(InvalidParamError): + update_file_tags( + arg_namespace(f"update_file_tags {FILE_UUID}"), uploadcare + ) + + +def test_cli_update_file_tags_without_flags_prints_error(capsys): + """`main()` turns the error into a message instead of a traceback.""" + main( + arg_namespace( + "--pub_key demopublickey --secret demosecretkey " + f"update_file_tags {FILE_UUID}" + ), + config_file_names=(), + ) + captured = capsys.readouterr() + assert "ERROR:" in captured.out + + +def test_cli_set_file_tags_accepts_no_tags(): + """Zero tags is a deliberate "clear the tags" request.""" + parsed = arg_namespace(f"set_file_tags {FILE_UUID}") + assert parsed.tags == [] + + +def test_cli_upload_parses_tags(): + parsed = arg_namespace("upload sample.txt --tags cat animal") + assert parsed.tags == ["cat", "animal"] + + +def test_cli_upload_without_tags(): + parsed = arg_namespace("upload sample.txt") + assert parsed.tags is None diff --git a/tests/integration/test_file_tags.py b/tests/integration/test_file_tags.py new file mode 100644 index 00000000..6b3e527d --- /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.set(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..70b72ebf --- /dev/null +++ b/tests/integration/ucare_cli/test_file_tags.py @@ -0,0 +1,105 @@ +"""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() diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 312b1c15..55e73207 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -1,9 +1,23 @@ # coding: utf-8 from __future__ import unicode_literals +from pathlib import Path from tempfile import NamedTemporaryFile +ASSETS_PATH = Path(__file__).parent / "assets" +IMAGE_PATH = ASSETS_PATH / "img.png" + + +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 upload_tmp_txt_file(uploadcare, content=""): tmp_txt_file = NamedTemporaryFile(mode="wb", delete=False) tmp_txt_file.write(content.encode("utf-8"))