From 6a9dfb8bcb614493e561a6a45ea91aac5fa4d32c Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Thu, 5 Sep 2024 20:19:04 -0400 Subject: [PATCH] Add support for filename, content-type and headers when uploading files The requests library has support for passing additional information, such as the intended filename on upload, the content-type and additional headers, by passing a tuple with 2, 3 or 4 elements instead of just a file object. See "POST a Multipart-Encoded File" in requests documentation for details: https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file Extend aptly_api files API to also be able to take similar tuples when uploading files to Aptly. One useful use case is to pass a proper package filename, in cases where packages are generated simply as .deb by upstream projects (usually via non native Debian build systems) but should more properly be stored as _:_.deb. Renaming files locally is a possibility, but potentially runs into permission issues. Being able to specify the filename to the API solves this in a more elegant way, without having to modify the local filesystem. The package information can be easily derived using debian.debfile.DebFile() to inspect a package file, in specific gencontrol() returns the fields of the control file which can be used to derive the expected filename. Tested locally by uploading files to aptly using the modified API. Also added a test (even though it mostly relies on mocks.) Confirmed mypy is happy with all the type annotation. --- aptly_api/parts/files.py | 23 ++++++++++++++++------- aptly_api/tests/test_files.py | 8 ++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/aptly_api/parts/files.py b/aptly_api/parts/files.py index c71b9e6..d3f5904 100644 --- a/aptly_api/parts/files.py +++ b/aptly_api/parts/files.py @@ -4,10 +4,16 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import os -from typing import Sequence, List, Tuple, BinaryIO, cast, Optional # noqa: F401 +from typing import Sequence, List, Tuple, TextIO, BinaryIO, cast, Optional, Union, Dict # noqa: F401 from aptly_api.base import BaseAPIClient, AptlyAPIException +_tuplefiletype = Union[ + Tuple[str, Union[TextIO, BinaryIO, str, bytes]], + Tuple[str, Union[TextIO, BinaryIO, str, bytes], str], + Tuple[str, Union[TextIO, BinaryIO, str, bytes], str, Dict[str, str]] +] + class FilesAPISection(BaseAPIClient): def list(self, directory: Optional[str] = None) -> Sequence[str]: @@ -18,13 +24,16 @@ def list(self, directory: Optional[str] = None) -> Sequence[str]: return cast(List[str], resp.json()) - def upload(self, destination: str, *files: str) -> Sequence[str]: - to_upload = [] # type: List[Tuple[str, BinaryIO]] + def upload(self, destination: str, *files: Union[str, _tuplefiletype]) -> Sequence[str]: + to_upload = [] # type: List[Tuple[str, Union[BinaryIO, _tuplefiletype]]] for f in files: - if not os.path.exists(f) or not os.access(f, os.R_OK): + if isinstance(f, tuple): + to_upload.append((f[0], f),) + elif not os.path.exists(f) or not os.access(f, os.R_OK): raise AptlyAPIException("File to upload %s can't be opened or read" % f) - fh = open(f, mode="rb") - to_upload.append((f, fh),) + else: + fh = open(f, mode="rb") + to_upload.append((f, fh),) try: resp = self.do_post("api/files/%s" % destination, @@ -33,7 +42,7 @@ def upload(self, destination: str, *files: str) -> Sequence[str]: raise finally: for fn, to_close in to_upload: - if not to_close.closed: + if not isinstance(to_close, tuple) and not to_close.closed: to_close.close() return cast(List[str], resp.json()) diff --git a/aptly_api/tests/test_files.py b/aptly_api/tests/test_files.py index 76f571b..f814534 100644 --- a/aptly_api/tests/test_files.py +++ b/aptly_api/tests/test_files.py @@ -45,6 +45,14 @@ def test_upload_failed(self, *, rmock: requests_mock.Mocker) -> None: with self.assertRaises(AptlyAPIException): self.fapi.upload("test", os.path.join(os.path.dirname(__file__), "testpkg.deb")) + def test_upload_with_tuples(self, *, rmock: requests_mock.Mocker) -> None: + rmock.post("http://test/api/files/test", text='["test/otherpkg.deb", "test/binpkg.deb"]') + with open(os.path.join(os.path.dirname(__file__), "testpkg.deb"), "rb") as pkgf: + self.assertSequenceEqual( + self.fapi.upload("test", ("otherpkg.deb", pkgf), ("binpkg.deb", b"dpkg-contents")), + ['test/otherpkg.deb', 'test/binpkg.deb'], + ) + def test_delete(self, *, rmock: requests_mock.Mocker) -> None: rmock.delete("http://test/api/files/test", text='{}')