From 15392a4ac480270dc85ec36deccb18cbbce17fcf Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Tue, 30 Jul 2024 16:17:15 +0200 Subject: [PATCH] Add attachment support (fixes #103) --- pyproject.toml | 1 + src/kinto_http/client.py | 36 +++++++++++++++++++++++++++ src/kinto_http/endpoints.py | 1 + tests/config/kinto.ini | 3 +++ tests/test_client.py | 22 +++++++++++++++++ tests/test_functional.py | 45 ++++++++++++++++++++++++++++++++++ tests/test_functional_async.py | 42 +++++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 79874679..d8939ac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ build-backend = "setuptools.build_meta" [project.optional-dependencies] dev = [ "kinto", + "kinto-attachment", "ruff", "pytest", "pytest-asyncio", diff --git a/src/kinto_http/client.py b/src/kinto_http/client.py index 10d1cd9c..3cde7943 100644 --- a/src/kinto_http/client.py +++ b/src/kinto_http/client.py @@ -1,7 +1,10 @@ import asyncio import functools import inspect +import json import logging +import mimetypes +import os import uuid from collections import OrderedDict from contextlib import contextmanager @@ -861,6 +864,39 @@ def purge_history(self, *, bucket=None, safe=True, if_match=None) -> List[Dict]: resp, _ = self.session.request("delete", endpoint, headers=headers) return resp["data"] + @retry_timeout + def add_attachment( + self, + id, + filepath, + bucket=None, + collection=None, + data=None, + permissions=None, + mimetype=None, + ): + with open(filepath, "rb") as f: + filecontent = f.read() + filename = os.path.basename(filepath) + if mimetype is None: + mimetype, _ = mimetypes.guess_type(filepath) + multipart = [("attachment", (filename, filecontent, mimetype))] + endpoint = self._get_endpoint("attachment", id=id, bucket=bucket, collection=collection) + resp, _ = self.session.request( + "post", + endpoint, + data=json.dumps(data) if data is not None else None, + permissions=json.dumps(permissions) if permissions is not None else None, + files=multipart, + ) + return resp + + @retry_timeout + def remove_attachment(self, id, bucket=None, collection=None): + endpoint = self._get_endpoint("attachment", id=id, bucket=bucket, collection=collection) + resp, _ = self.session.request("delete", endpoint) + return resp + def __repr__(self) -> str: if self.collection_name: endpoint = self._get_endpoint( diff --git a/src/kinto_http/endpoints.py b/src/kinto_http/endpoints.py index 991b8343..5844aed7 100644 --- a/src/kinto_http/endpoints.py +++ b/src/kinto_http/endpoints.py @@ -20,6 +20,7 @@ class Endpoints(object): "collection": "{root}/buckets/{bucket}/collections/{collection}", "records": "{root}/buckets/{bucket}/collections/{collection}/records", # NOQA "record": "{root}/buckets/{bucket}/collections/{collection}/records/{id}", # NOQA + "attachment": "{root}/buckets/{bucket}/collections/{collection}/records/{id}/attachment", # NOQA } def __init__(self, root=""): diff --git a/tests/config/kinto.ini b/tests/config/kinto.ini index 5721bd6c..a60038f7 100644 --- a/tests/config/kinto.ini +++ b/tests/config/kinto.ini @@ -6,6 +6,7 @@ kinto.paginate_by = 5 kinto.includes = kinto.plugins.flush kinto.plugins.accounts + kinto_attachment multiauth.policies = account multiauth.policy.account.use = kinto.plugins.accounts.AccountsPolicy @@ -13,6 +14,8 @@ kinto.account_create_principals = system.Everyone kinto.account_write_principals = account:user kinto.bucket_create_principals = account:user +kinto.attachment.base_path = /tmp + [server:main] use = egg:waitress#main host = 0.0.0.0 diff --git a/tests/test_client.py b/tests/test_client.py index 671a3fb3..bad541c0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1391,3 +1391,25 @@ def test_purging_of_history(client_setup: Client): client.purge_history(bucket="mybucket") url = "/buckets/mybucket/history" client.session.request.assert_called_with("delete", url, headers=None) + + +def test_add_attachment_guesses_mimetype(record_setup: Client, tmp_path): + client = record_setup + mock_response(client.session) + + p = tmp_path / "file.txt" + p.write_text("hello") + client.add_attachment( + id="abc", + bucket="a", + collection="b", + filepath=p, + ) + + client.session.request.assert_called_with( + "post", + "/buckets/a/collections/b/records/abc/attachment", + data=None, + permissions=None, + files=[("attachment", ("file.txt", b"hello", "text/plain"))], + ) diff --git a/tests/test_functional.py b/tests/test_functional.py index e03ac495..f667ab10 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -536,3 +536,48 @@ def test_replication(functional_setup): replication.replicate(origin, destination) records = client.get_records(bucket="destination", collection="coll") assert len(records) == 10 + + +def test_adding_an_attachment(functional_setup, tmp_path): + client = functional_setup + with client.batch(bucket="mozilla", collection="payments") as batch: + batch.create_bucket() + batch.create_collection() + + p = tmp_path / "file.txt" + p.write_text("hello") + + client.add_attachment( + id="abc", + filepath=p, + bucket="mozilla", + collection="payments", + data={"secret": "psssssst!"}, + permissions={"write": ["system.Everyone"]}, + mimetype="text/custom", + ) + + record = client.get_record(bucket="mozilla", collection="payments", id="abc") + assert "attachment" in record["data"] + assert record["data"]["attachment"]["filename"] == "file.txt" + assert record["data"]["attachment"]["mimetype"] == "text/custom" + assert "secret" in record["data"] + assert "system.Everyone" in record["permissions"]["write"] + + +def test_removing_an_attachment(functional_setup, tmp_path): + client = functional_setup.clone(bucket="mozilla", collection="payments") + with client.batch() as batch: + batch.create_bucket() + batch.create_collection() + p = tmp_path / "file.txt" + p.write_text("hello") + client.add_attachment( + id="abc", + filepath=p, + ) + + client.remove_attachment(id="abc") + + record = client.get_record(id="abc") + assert record["data"]["attachment"] is None diff --git a/tests/test_functional_async.py b/tests/test_functional_async.py index 5b854997..fd4bba93 100644 --- a/tests/test_functional_async.py +++ b/tests/test_functional_async.py @@ -518,3 +518,45 @@ async def test_patch_record_jsonpatch(functional_async_setup): assert record["data"]["hello"] == "world" assert record["data"]["goodnight"] == "moon" assert record["permissions"]["read"] == ["alice"] + + +async def test_adding_an_attachment(functional_async_setup, tmp_path): + client = functional_async_setup.clone(bucket="mozilla", collection="payments") + await client.create_bucket() + await client.create_collection() + + p = tmp_path / "file.txt" + p.write_text("hello") + + await client.add_attachment( + id="abc", + filepath=p, + data={"secret": "psssssst!"}, + permissions={"write": ["system.Everyone"]}, + mimetype="text/custom", + ) + + record = await client.get_record(id="abc") + assert "attachment" in record["data"] + assert record["data"]["attachment"]["filename"] == "file.txt" + assert record["data"]["attachment"]["mimetype"] == "text/custom" + assert "secret" in record["data"] + assert "system.Everyone" in record["permissions"]["write"] + + +async def test_removing_an_attachment(functional_async_setup, tmp_path): + client = functional_async_setup.clone(bucket="mozilla", collection="payments") + await client.create_bucket() + await client.create_collection() + + p = tmp_path / "file.txt" + p.write_text("hello") + await client.add_attachment( + id="abc", + filepath=p, + ) + + await client.remove_attachment(id="abc") + + record = await client.get_record(id="abc") + assert record["data"]["attachment"] is None