From 15392a4ac480270dc85ec36deccb18cbbce17fcf Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Tue, 30 Jul 2024 16:17:15 +0200 Subject: [PATCH 1/3] 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 From 202a4de88ca173466d6f9f0f0dc5584d71e9c9e6 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Mon, 4 Nov 2024 18:10:11 +0100 Subject: [PATCH 2/3] Run make format with latest version --- tests/conftest.py | 3 ++- tests/support.py | 1 + tests/test_async_client.py | 3 ++- tests/test_batch.py | 3 ++- tests/test_cli_utils.py | 3 ++- tests/test_client.py | 3 ++- tests/test_endpoints.py | 1 + tests/test_functional.py | 1 + tests/test_functional_async.py | 3 ++- tests/test_logging.py | 3 ++- tests/test_replication.py | 3 ++- tests/test_session.py | 5 +++-- 12 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b2931751..3b9162e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,13 @@ import pytest import requests +from pytest_mock.plugin import MockerFixture + from kinto_http import AsyncClient, Client from kinto_http.constants import DEFAULT_AUTH, SERVER_URL, USER_AGENT from kinto_http.endpoints import Endpoints from kinto_http.exceptions import KintoException from kinto_http.session import Session -from pytest_mock.plugin import MockerFixture from .support import create_user, get_200, get_503, mock_response diff --git a/tests/support.py b/tests/support.py index bb9e9a18..2054a0d1 100644 --- a/tests/support.py +++ b/tests/support.py @@ -5,6 +5,7 @@ from urllib.parse import urljoin import requests + from kinto_http.constants import DEFAULT_AUTH from kinto_http.exceptions import KintoException diff --git a/tests/test_async_client.py b/tests/test_async_client.py index b5ab166e..01ce9b23 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1,12 +1,13 @@ import re import pytest +from pytest_mock import MockerFixture + from kinto_http import AsyncClient as Client from kinto_http import BearerTokenAuth, BucketNotFound, KintoException from kinto_http.constants import DO_NOT_OVERWRITE, SERVER_URL from kinto_http.patch_type import JSONPatch, MergePatch from kinto_http.session import create_session -from pytest_mock import MockerFixture from .support import build_response, get_http_error, mock_response diff --git a/tests/test_batch.py b/tests/test_batch.py index 548c7f40..60af5cb6 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,8 +1,9 @@ import pytest +from pytest_mock.plugin import MockerFixture + from kinto_http import Client from kinto_http.batch import BatchSession from kinto_http.exceptions import KintoException -from pytest_mock.plugin import MockerFixture def test_requests_are_stacked(batch_setup: Client, mocker: MockerFixture): diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py index 0bb33489..9b6255d9 100644 --- a/tests/test_cli_utils.py +++ b/tests/test_cli_utils.py @@ -1,8 +1,9 @@ import argparse +from pytest_mock import MockerFixture + from kinto_http import BearerTokenAuth, cli_utils from kinto_http.constants import ALL_PARAMETERS -from pytest_mock import MockerFixture from .support import assert_option_strings diff --git a/tests/test_client.py b/tests/test_client.py index bad541c0..205d3519 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,8 @@ import re import pytest +from pytest_mock.plugin import MockerFixture + from kinto_http import ( BearerTokenAuth, BucketNotFound, @@ -11,7 +13,6 @@ ) from kinto_http.constants import DO_NOT_OVERWRITE, SERVER_URL from kinto_http.patch_type import JSONPatch, MergePatch -from pytest_mock.plugin import MockerFixture from .support import build_response, get_http_error, mock_response diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 779c3042..162d2431 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,6 +1,7 @@ from typing import Dict, Tuple import pytest + from kinto_http import KintoException from kinto_http.endpoints import Endpoints diff --git a/tests/test_functional.py b/tests/test_functional.py index f667ab10..adbbd183 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -2,6 +2,7 @@ from unittest import mock import pytest + from kinto_http import BucketNotFound, CollectionNotFound, KintoException, replication from kinto_http.patch_type import JSONPatch diff --git a/tests/test_functional_async.py b/tests/test_functional_async.py index fd4bba93..57215487 100644 --- a/tests/test_functional_async.py +++ b/tests/test_functional_async.py @@ -1,7 +1,8 @@ import pytest +from pytest_mock import MockerFixture + from kinto_http import BucketNotFound, CollectionNotFound, KintoException from kinto_http.patch_type import JSONPatch -from pytest_mock import MockerFixture from .support import get_user_id diff --git a/tests/test_logging.py b/tests/test_logging.py index 0182dcef..e2230363 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,6 +1,7 @@ -from kinto_http import Client from pytest_mock.plugin import MockerFixture +from kinto_http import Client + def test_create_bucket_logs_info_message(client_setup: Client, mocker: MockerFixture): mocked_logger = mocker.patch("kinto_http.client.logger") diff --git a/tests/test_replication.py b/tests/test_replication.py index 61e35d90..1af91087 100644 --- a/tests/test_replication.py +++ b/tests/test_replication.py @@ -1,6 +1,7 @@ +from pytest_mock import MockerFixture + from kinto_http import Client, exceptions from kinto_http.replication import replicate -from pytest_mock import MockerFixture from .support import mock_response diff --git a/tests/test_session.py b/tests/test_session.py index 0aefb5c6..da24c5c2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -5,13 +5,14 @@ from typing import Tuple from unittest.mock import MagicMock -import kinto_http import pkg_resources import pytest +from pytest_mock.plugin import MockerFixture + +import kinto_http from kinto_http.constants import USER_AGENT from kinto_http.exceptions import BackoffException, KintoException from kinto_http.session import Session, create_session -from pytest_mock.plugin import MockerFixture from .support import get_200, get_403, get_503, get_http_response From e59ee5022f67f32c82ad1849d3b8055c027a3049 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Mon, 4 Nov 2024 18:12:35 +0100 Subject: [PATCH 3/3] Mention new methods in README --- README.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.rst b/README.rst index eda6a7c5..638ee3b1 100644 --- a/README.rst +++ b/README.rst @@ -391,6 +391,22 @@ The history of a bucket can also be purged with: client.purge_history(bucket='default') +Attachments +----------- + +If the `kinto-attachment plugin `_ is enabled, it is possible to add attachments on records: + +.. code-block:: python + + client.add_attachment(id="record-id", filepath="/path/to/image.png") + +Or remove them: + +.. code-block:: python + + client.remove_attachment(id="record-id") + + Endpoint URLs -------------