Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add attachment support (fixes #103) #374

Merged
merged 3 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/Kinto/kinto-attachment/>`_ 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
-------------

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ build-backend = "setuptools.build_meta"
[project.optional-dependencies]
dev = [
"kinto",
"kinto-attachment",
"ruff",
"pytest",
"pytest-asyncio",
Expand Down
36 changes: 36 additions & 0 deletions src/kinto_http/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/kinto_http/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""):
Expand Down
3 changes: 3 additions & 0 deletions tests/config/kinto.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ 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
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
Expand Down
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from urllib.parse import urljoin

import requests

from kinto_http.constants import DEFAULT_AUTH
from kinto_http.exceptions import KintoException

Expand Down
3 changes: 2 additions & 1 deletion tests/test_async_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion tests/test_batch.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cli_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
25 changes: 24 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re

import pytest
from pytest_mock.plugin import MockerFixture

from kinto_http import (
BearerTokenAuth,
BucketNotFound,
Expand All @@ -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

Expand Down Expand Up @@ -1391,3 +1392,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"))],
)
1 change: 1 addition & 0 deletions tests/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Dict, Tuple

import pytest

from kinto_http import KintoException
from kinto_http.endpoints import Endpoints

Expand Down
46 changes: 46 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -536,3 +537,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
45 changes: 44 additions & 1 deletion tests/test_functional_async.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -518,3 +519,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
3 changes: 2 additions & 1 deletion tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
3 changes: 2 additions & 1 deletion tests/test_replication.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 3 additions & 2 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading