From 60da85afbfea82af7bcf9631a827172c75a83e35 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Wed, 28 Oct 2026 23:20:49 +0100 Subject: [PATCH 1/2] feat: regenerate from the production spec (POST /files, GET /files/{id}) and add files.upload Regenerated from the production spec: the files routes and the photo_ids documentation. files.register and files.get wrap them; files.upload registers a file and PUTs its bytes (no API key on the PUT, retried like any call). Not released by this change. --- .spec-sha256 | 2 +- README.md | 2 + src/controlresell/client.py | 50 ++++ .../generated/api/files/__init__.py | 1 + .../generated/api/files/get_file.py | 206 ++++++++++++++ .../generated/api/files/upload_file.py | 254 ++++++++++++++++++ .../generated/api/items/create_item.py | 116 ++++---- .../generated/api/items/update_item.py | 112 ++++---- .../generated/models/__init__.py | 6 + src/controlresell/generated/models/file.py | 101 +++++++ .../generated/models/file_upload.py | 70 +++++ .../generated/models/file_upload_request.py | 77 ++++++ tests/test_client.py | 37 +++ 13 files changed, 921 insertions(+), 113 deletions(-) create mode 100644 src/controlresell/generated/api/files/__init__.py create mode 100644 src/controlresell/generated/api/files/get_file.py create mode 100644 src/controlresell/generated/api/files/upload_file.py create mode 100644 src/controlresell/generated/models/file.py create mode 100644 src/controlresell/generated/models/file_upload.py create mode 100644 src/controlresell/generated/models/file_upload_request.py diff --git a/.spec-sha256 b/.spec-sha256 index ebd3a33..bbe8884 100644 --- a/.spec-sha256 +++ b/.spec-sha256 @@ -1 +1 @@ -06a7c92e23df7c44f8898a2b18abec43aa5c14b8f12ea10e1ce65a94a14a0fb4 +e8f81ea407d93977cf0612e9d7e4bbda4b86c4d25de84bf44152b2ff868cde03 diff --git a/README.md b/README.md index 8a3b8a9..edf17d1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ from controlresell.generated.models import ItemCreateRequest, ItemUpdateRequest, client.items.create(ItemCreateRequest(name="Jacket")) # client.items.update(42, ItemUpdateRequest(...)) patches client.items.publish(42, PublishRequest(account_ids=[""])) +photo = client.files.upload(open("jacket.jpg", "rb").read(), "jacket.jpg", "image/jpeg") # registers the photo and PUTs its bytes; files.register only registers it, files.get reads it (status is PENDING until its bytes are stored) +client.items.create(ItemCreateRequest(name="Jacket", photo_ids=[photo.id])) client.items.publish(42, PublishRequest(account_ids=[""], scheduled_at=datetime(2026, 11, 2, 10, 0, tzinfo=timezone.utc))) # publish, price_drop and republish take an optional scheduled_at, at most 30 days ahead; without it they run now client.conversations.send_message("", SendMessageRequest(text="Hello!")) diff --git a/src/controlresell/client.py b/src/controlresell/client.py index d920250..2601c47 100644 --- a/src/controlresell/client.py +++ b/src/controlresell/client.py @@ -45,6 +45,7 @@ list_catalog_sizes, ) from .generated.api.customers import get_customer, list_customers +from .generated.api.files import get_file, upload_file from .generated.api.favorites import list_favorites, list_item_favorites from .generated.api.webhooks import ( create_webhook_endpoint, @@ -82,6 +83,9 @@ Customer, CustomerPage, Favorite, + File, + FileUpload, + FileUploadRequest, FavoritePage, Item, ItemCreateRequest, @@ -153,6 +157,12 @@ def __init__( key = api_key or os.environ.get(API_KEY_VARIABLE) if not key: raise ValueError(f"No API key: pass api_key or set {API_KEY_VARIABLE}.") + # The bytes of a file go to the storage address, which is signed: no API key there. + self._storage = httpx.Client( + timeout=httpx.Timeout(timeout) if timeout is not None else None, + headers={"User-Agent": f"controlresell-python/{__version__}"}, + transport=RetryTransport(transport or httpx.HTTPTransport(), retry), + ) self._client = AuthenticatedClient( base_url=base_url, token=key, @@ -166,6 +176,7 @@ def __init__( self.conversations = _Conversations(self) self.orders = _Orders(self) self.customers = _Customers(self) + self.files = _Files(self) self.favorites = _Favorites(self) self.catalogs = _Catalogs(self) self.webhooks = _Webhooks(self) @@ -177,6 +188,7 @@ def http(self) -> httpx.Client: def close(self) -> None: self._client.get_httpx_client().close() + self._storage.close() def __enter__(self) -> ControlResell: return self @@ -463,6 +475,44 @@ def iter( ) +class _Files: + """The photos of the seller's library: register one, send its bytes, read it.""" + + def __init__(self, sdk: ControlResell) -> None: + self._sdk = sdk + + def register( + self, filename: str, content_type: str, size: int, *, idempotency_key: str | None = None + ) -> FileUpload: + """Registers a file (jpeg, png, webp, heic or heif, at most 20 MiB): its identifier and the address where to + ``PUT`` its bytes within 30 minutes (needs ``items:write``).""" + body = FileUploadRequest(filename=filename, content_type=content_type, size=size) + return self._sdk._call(upload_file, body=body, idempotency_key=_key(idempotency_key)) # noqa: SLF001 + + def get(self, id: UUID | str) -> File: + """One file of the library, with a signed link to read it; ``status`` is ``PENDING`` until its bytes are stored.""" + return self._sdk._call(get_file, id=UUID(str(id))) # noqa: SLF001 + + def upload( + self, data: bytes, filename: str, content_type: str, *, idempotency_key: str | None = None + ) -> FileUpload: + """Registers a file and sends its bytes (an HTTP ``PUT`` with the same ``Content-Type``, no API key), and returns + the registration: put its ``id`` in ``photo_ids``. The ``PUT`` is retried like any call; a refused one is raised + as a ``ControlResellError`` (the file stays ``PENDING``).""" + upload = self.register(filename, content_type, len(data), idempotency_key=idempotency_key) + if not upload.upload_url: + raise ValueError(f"The API returned no upload address for file {upload.id}.") + try: + response = self._sdk._storage.put( # noqa: SLF001 + upload.upload_url, content=data, headers={"Content-Type": content_type} + ) + except httpx.TransportError as cause: + raise ControlResellConnectionError(cause) from cause + if not response.is_success: + raise error_of(response.status_code, None, response.headers) + return upload + + class _Customers: """The other parties of the seller's sales and purchases (needs the ``customers:read`` scope).""" diff --git a/src/controlresell/generated/api/files/__init__.py b/src/controlresell/generated/api/files/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/src/controlresell/generated/api/files/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/controlresell/generated/api/files/get_file.py b/src/controlresell/generated/api/files/get_file.py new file mode 100644 index 0000000..9f982e3 --- /dev/null +++ b/src/controlresell/generated/api/files/get_file.py @@ -0,0 +1,206 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote +from uuid import UUID + +import httpx + +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.file import File +from ...types import Response + + +def _get_kwargs( + id: UUID, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/public/v1/files/{id}".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | File: + if response.status_code == 200: + response_200 = File.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 402: + response_402 = ErrorResponse.from_dict(response.json()) + + return response_402 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + response_default = ErrorResponse.from_dict(response.json()) + + return response_default + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | File]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorResponse | File]: + """Read one file of the seller's library + + One file of the seller's library, by its identifier, with a signed link to read it (it expires: ask + again rather than keep it). `status` is `PENDING` while the bytes are not on our storage yet. + Requires `items:read`. + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | File] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + id: UUID, + *, + client: AuthenticatedClient | Client, +) -> ErrorResponse | File | None: + """Read one file of the seller's library + + One file of the seller's library, by its identifier, with a signed link to read it (it expires: ask + again rather than keep it). `status` is `PENDING` while the bytes are not on our storage yet. + Requires `items:read`. + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | File + """ + + return sync_detailed( + id=id, + client=client, + ).parsed + + +async def asyncio_detailed( + id: UUID, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorResponse | File]: + """Read one file of the seller's library + + One file of the seller's library, by its identifier, with a signed link to read it (it expires: ask + again rather than keep it). `status` is `PENDING` while the bytes are not on our storage yet. + Requires `items:read`. + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | File] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + id: UUID, + *, + client: AuthenticatedClient | Client, +) -> ErrorResponse | File | None: + """Read one file of the seller's library + + One file of the seller's library, by its identifier, with a signed link to read it (it expires: ask + again rather than keep it). `status` is `PENDING` while the bytes are not on our storage yet. + Requires `items:read`. + + Args: + id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | File + """ + + return ( + await asyncio_detailed( + id=id, + client=client, + ) + ).parsed diff --git a/src/controlresell/generated/api/files/upload_file.py b/src/controlresell/generated/api/files/upload_file.py new file mode 100644 index 0000000..209f6bd --- /dev/null +++ b/src/controlresell/generated/api/files/upload_file.py @@ -0,0 +1,254 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.file_upload import FileUpload +from ...models.file_upload_request import FileUploadRequest +from ...types import Response + + +def _get_kwargs( + *, + body: FileUploadRequest, + idempotency_key: str, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/public/v1/files", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | FileUpload: + if response.status_code == 201: + response_201 = FileUpload.from_dict(response.json()) + + return response_201 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 402: + response_402 = ErrorResponse.from_dict(response.json()) + + return response_402 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if response.status_code == 503: + response_503 = ErrorResponse.from_dict(response.json()) + + return response_503 + + response_default = ErrorResponse.from_dict(response.json()) + + return response_default + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | FileUpload]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: FileUploadRequest, + idempotency_key: str, +) -> Response[ErrorResponse | FileUpload]: + """Register a file to send + + Registers a file in the seller's library and returns its identifier and the address where to send + its bytes: an HTTP `PUT` of the file to `uploadUrl`, with the same `Content-Type` and exactly `size` + bytes as the body, and no `Authorization` header, within 30 minutes. Photos only (`image/jpeg`, + `image/png`, `image/webp`, `image/heic`, `image/heif`), at most 20 MiB. Then put the identifier in + `photoIds` of `POST /items` or `PATCH /items/{id}`; an identifier whose bytes were never sent is + refused there (`404 photo_not_found`). The address is valid for 30 minutes: a replay of the same + `Idempotency-Key` returns the same address, so register the file again with a new key once it has + expired. Requires `items:write` and an `Idempotency-Key`. + + Args: + idempotency_key (str): + body (FileUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | FileUpload] + """ + + kwargs = _get_kwargs( + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: FileUploadRequest, + idempotency_key: str, +) -> ErrorResponse | FileUpload | None: + """Register a file to send + + Registers a file in the seller's library and returns its identifier and the address where to send + its bytes: an HTTP `PUT` of the file to `uploadUrl`, with the same `Content-Type` and exactly `size` + bytes as the body, and no `Authorization` header, within 30 minutes. Photos only (`image/jpeg`, + `image/png`, `image/webp`, `image/heic`, `image/heif`), at most 20 MiB. Then put the identifier in + `photoIds` of `POST /items` or `PATCH /items/{id}`; an identifier whose bytes were never sent is + refused there (`404 photo_not_found`). The address is valid for 30 minutes: a replay of the same + `Idempotency-Key` returns the same address, so register the file again with a new key once it has + expired. Requires `items:write` and an `Idempotency-Key`. + + Args: + idempotency_key (str): + body (FileUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | FileUpload + """ + + return sync_detailed( + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: FileUploadRequest, + idempotency_key: str, +) -> Response[ErrorResponse | FileUpload]: + """Register a file to send + + Registers a file in the seller's library and returns its identifier and the address where to send + its bytes: an HTTP `PUT` of the file to `uploadUrl`, with the same `Content-Type` and exactly `size` + bytes as the body, and no `Authorization` header, within 30 minutes. Photos only (`image/jpeg`, + `image/png`, `image/webp`, `image/heic`, `image/heif`), at most 20 MiB. Then put the identifier in + `photoIds` of `POST /items` or `PATCH /items/{id}`; an identifier whose bytes were never sent is + refused there (`404 photo_not_found`). The address is valid for 30 minutes: a replay of the same + `Idempotency-Key` returns the same address, so register the file again with a new key once it has + expired. Requires `items:write` and an `Idempotency-Key`. + + Args: + idempotency_key (str): + body (FileUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | FileUpload] + """ + + kwargs = _get_kwargs( + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: FileUploadRequest, + idempotency_key: str, +) -> ErrorResponse | FileUpload | None: + """Register a file to send + + Registers a file in the seller's library and returns its identifier and the address where to send + its bytes: an HTTP `PUT` of the file to `uploadUrl`, with the same `Content-Type` and exactly `size` + bytes as the body, and no `Authorization` header, within 30 minutes. Photos only (`image/jpeg`, + `image/png`, `image/webp`, `image/heic`, `image/heif`), at most 20 MiB. Then put the identifier in + `photoIds` of `POST /items` or `PATCH /items/{id}`; an identifier whose bytes were never sent is + refused there (`404 photo_not_found`). The address is valid for 30 minutes: a replay of the same + `Idempotency-Key` returns the same address, so register the file again with a new key once it has + expired. Requires `items:write` and an `Idempotency-Key`. + + Args: + idempotency_key (str): + body (FileUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | FileUpload + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/src/controlresell/generated/api/items/create_item.py b/src/controlresell/generated/api/items/create_item.py index 19486a2..d7bac31 100644 --- a/src/controlresell/generated/api/items/create_item.py +++ b/src/controlresell/generated/api/items/create_item.py @@ -116,20 +116,21 @@ def sync_detailed( Creates one item in the seller's ControlResell stock, unsold unless `sold` says otherwise, without publishing it: publish it with `POST /items/{id}/publish`. Only `name` is required. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with - `422 currency_mismatch`). `photoIds` are files already in the seller's library: another seller's, or - an unknown one, is `404 photo_not_found`. A label the seller does not have yet is created. The - fields a marketplace asks for are written with the item: `packageSizeId`, `materialIds`, - `measurementLength` and `measurementWidth` (centimeters), `enumValues` (the category's enumerated - fields, `{enumKey, enumValue}`, one entry per picked value), `attributes` (its free-text fields, - `{fieldKey, value}`, one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); - each list replaces the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom - field of another seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. - `categoryId`, `sizeId` and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and - enumerated values are trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to - 255 for a value; two entries that differ only by case or accents (as the database compares them) are - a 400. At most 100 entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, - `enumValues`, `attributes` and `customFields` are sorted by key (then value, or field id), as plain - strings (upper case before lower case). Requires `items:write` and an `Idempotency-Key`. + `422 currency_mismatch`). `photoIds` are files already in the seller's library (`POST /files`): + another seller's, an unknown one, or one whose bytes were never sent is `404 photo_not_found`. A + label the seller does not have yet is created. The fields a marketplace asks for are written with + the item: `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), + `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), + `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the + seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place + (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is + `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 + (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters + (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or + accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes + (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by + key (then value, or field id), as plain strings (upper case before lower case). Requires + `items:write` and an `Idempotency-Key`. Args: idempotency_key (str): @@ -166,20 +167,21 @@ def sync( Creates one item in the seller's ControlResell stock, unsold unless `sold` says otherwise, without publishing it: publish it with `POST /items/{id}/publish`. Only `name` is required. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with - `422 currency_mismatch`). `photoIds` are files already in the seller's library: another seller's, or - an unknown one, is `404 photo_not_found`. A label the seller does not have yet is created. The - fields a marketplace asks for are written with the item: `packageSizeId`, `materialIds`, - `measurementLength` and `measurementWidth` (centimeters), `enumValues` (the category's enumerated - fields, `{enumKey, enumValue}`, one entry per picked value), `attributes` (its free-text fields, - `{fieldKey, value}`, one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); - each list replaces the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom - field of another seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. - `categoryId`, `sizeId` and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and - enumerated values are trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to - 255 for a value; two entries that differ only by case or accents (as the database compares them) are - a 400. At most 100 entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, - `enumValues`, `attributes` and `customFields` are sorted by key (then value, or field id), as plain - strings (upper case before lower case). Requires `items:write` and an `Idempotency-Key`. + `422 currency_mismatch`). `photoIds` are files already in the seller's library (`POST /files`): + another seller's, an unknown one, or one whose bytes were never sent is `404 photo_not_found`. A + label the seller does not have yet is created. The fields a marketplace asks for are written with + the item: `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), + `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), + `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the + seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place + (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is + `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 + (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters + (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or + accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes + (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by + key (then value, or field id), as plain strings (upper case before lower case). Requires + `items:write` and an `Idempotency-Key`. Args: idempotency_key (str): @@ -211,20 +213,21 @@ async def asyncio_detailed( Creates one item in the seller's ControlResell stock, unsold unless `sold` says otherwise, without publishing it: publish it with `POST /items/{id}/publish`. Only `name` is required. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with - `422 currency_mismatch`). `photoIds` are files already in the seller's library: another seller's, or - an unknown one, is `404 photo_not_found`. A label the seller does not have yet is created. The - fields a marketplace asks for are written with the item: `packageSizeId`, `materialIds`, - `measurementLength` and `measurementWidth` (centimeters), `enumValues` (the category's enumerated - fields, `{enumKey, enumValue}`, one entry per picked value), `attributes` (its free-text fields, - `{fieldKey, value}`, one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); - each list replaces the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom - field of another seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. - `categoryId`, `sizeId` and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and - enumerated values are trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to - 255 for a value; two entries that differ only by case or accents (as the database compares them) are - a 400. At most 100 entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, - `enumValues`, `attributes` and `customFields` are sorted by key (then value, or field id), as plain - strings (upper case before lower case). Requires `items:write` and an `Idempotency-Key`. + `422 currency_mismatch`). `photoIds` are files already in the seller's library (`POST /files`): + another seller's, an unknown one, or one whose bytes were never sent is `404 photo_not_found`. A + label the seller does not have yet is created. The fields a marketplace asks for are written with + the item: `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), + `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), + `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the + seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place + (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is + `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 + (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters + (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or + accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes + (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by + key (then value, or field id), as plain strings (upper case before lower case). Requires + `items:write` and an `Idempotency-Key`. Args: idempotency_key (str): @@ -259,20 +262,21 @@ async def asyncio( Creates one item in the seller's ControlResell stock, unsold unless `sold` says otherwise, without publishing it: publish it with `POST /items/{id}/publish`. Only `name` is required. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with - `422 currency_mismatch`). `photoIds` are files already in the seller's library: another seller's, or - an unknown one, is `404 photo_not_found`. A label the seller does not have yet is created. The - fields a marketplace asks for are written with the item: `packageSizeId`, `materialIds`, - `measurementLength` and `measurementWidth` (centimeters), `enumValues` (the category's enumerated - fields, `{enumKey, enumValue}`, one entry per picked value), `attributes` (its free-text fields, - `{fieldKey, value}`, one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); - each list replaces the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom - field of another seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. - `categoryId`, `sizeId` and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and - enumerated values are trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to - 255 for a value; two entries that differ only by case or accents (as the database compares them) are - a 400. At most 100 entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, - `enumValues`, `attributes` and `customFields` are sorted by key (then value, or field id), as plain - strings (upper case before lower case). Requires `items:write` and an `Idempotency-Key`. + `422 currency_mismatch`). `photoIds` are files already in the seller's library (`POST /files`): + another seller's, an unknown one, or one whose bytes were never sent is `404 photo_not_found`. A + label the seller does not have yet is created. The fields a marketplace asks for are written with + the item: `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), + `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), + `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the + seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place + (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is + `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 + (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters + (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or + accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes + (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by + key (then value, or field id), as plain strings (upper case before lower case). Requires + `items:write` and an `Idempotency-Key`. Args: idempotency_key (str): diff --git a/src/controlresell/generated/api/items/update_item.py b/src/controlresell/generated/api/items/update_item.py index 7220845..33a4215 100644 --- a/src/controlresell/generated/api/items/update_item.py +++ b/src/controlresell/generated/api/items/update_item.py @@ -110,20 +110,20 @@ def sync_detailed( replaces the whole list. It changes the item in ControlResell only, not its listings on the marketplaces. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with `422 currency_mismatch`). `photoIds` are files already in the - seller's library: another seller's, or an unknown one, is `404 photo_not_found`. A label the seller - does not have yet is created. The fields a marketplace asks for are written with the item: - `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), - `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), - `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the - seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place - (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is - `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 - (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters - (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or - accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes - (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by - key (then value, or field id), as plain strings (upper case before lower case). Requires - `items:write`. + seller's library (`POST /files`): another seller's, an unknown one, or one whose bytes were never + sent is `404 photo_not_found`. A label the seller does not have yet is created. The fields a + marketplace asks for are written with the item: `packageSizeId`, `materialIds`, `measurementLength` + and `measurementWidth` (centimeters), `enumValues` (the category's enumerated fields, `{enumKey, + enumValue}`, one entry per picked value), `attributes` (its free-text fields, `{fieldKey, value}`, + one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); each list replaces + the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another + seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` + and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and enumerated values are + trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two + entries that differ only by case or accents (as the database compares them) are a 400. At most 100 + entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, `enumValues`, + `attributes` and `customFields` are sorted by key (then value, or field id), as plain strings (upper + case before lower case). Requires `items:write`. Args: id (int): @@ -162,20 +162,20 @@ def sync( replaces the whole list. It changes the item in ControlResell only, not its listings on the marketplaces. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with `422 currency_mismatch`). `photoIds` are files already in the - seller's library: another seller's, or an unknown one, is `404 photo_not_found`. A label the seller - does not have yet is created. The fields a marketplace asks for are written with the item: - `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), - `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), - `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the - seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place - (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is - `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 - (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters - (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or - accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes - (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by - key (then value, or field id), as plain strings (upper case before lower case). Requires - `items:write`. + seller's library (`POST /files`): another seller's, an unknown one, or one whose bytes were never + sent is `404 photo_not_found`. A label the seller does not have yet is created. The fields a + marketplace asks for are written with the item: `packageSizeId`, `materialIds`, `measurementLength` + and `measurementWidth` (centimeters), `enumValues` (the category's enumerated fields, `{enumKey, + enumValue}`, one entry per picked value), `attributes` (its free-text fields, `{fieldKey, value}`, + one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); each list replaces + the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another + seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` + and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and enumerated values are + trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two + entries that differ only by case or accents (as the database compares them) are a 400. At most 100 + entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, `enumValues`, + `attributes` and `customFields` are sorted by key (then value, or field id), as plain strings (upper + case before lower case). Requires `items:write`. Args: id (int): @@ -209,20 +209,20 @@ async def asyncio_detailed( replaces the whole list. It changes the item in ControlResell only, not its listings on the marketplaces. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with `422 currency_mismatch`). `photoIds` are files already in the - seller's library: another seller's, or an unknown one, is `404 photo_not_found`. A label the seller - does not have yet is created. The fields a marketplace asks for are written with the item: - `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), - `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), - `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the - seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place - (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is - `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 - (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters - (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or - accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes - (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by - key (then value, or field id), as plain strings (upper case before lower case). Requires - `items:write`. + seller's library (`POST /files`): another seller's, an unknown one, or one whose bytes were never + sent is `404 photo_not_found`. A label the seller does not have yet is created. The fields a + marketplace asks for are written with the item: `packageSizeId`, `materialIds`, `measurementLength` + and `measurementWidth` (centimeters), `enumValues` (the category's enumerated fields, `{enumKey, + enumValue}`, one entry per picked value), `attributes` (its free-text fields, `{fieldKey, value}`, + one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); each list replaces + the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another + seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` + and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and enumerated values are + trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two + entries that differ only by case or accents (as the database compares them) are a 400. At most 100 + entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, `enumValues`, + `attributes` and `customFields` are sorted by key (then value, or field id), as plain strings (upper + case before lower case). Requires `items:write`. Args: id (int): @@ -259,20 +259,20 @@ async def asyncio( replaces the whole list. It changes the item in ControlResell only, not its listings on the marketplaces. The amounts are `{amount, currency}` in the item's currency (EUR for a new item; another currency is refused with `422 currency_mismatch`). `photoIds` are files already in the - seller's library: another seller's, or an unknown one, is `404 photo_not_found`. A label the seller - does not have yet is created. The fields a marketplace asks for are written with the item: - `packageSizeId`, `materialIds`, `measurementLength` and `measurementWidth` (centimeters), - `enumValues` (the category's enumerated fields, `{enumKey, enumValue}`, one entry per picked value), - `attributes` (its free-text fields, `{fieldKey, value}`, one per key) and `customFields` (the - seller's own fields, `{fieldId, value}`); each list replaces the item's whole list. A place - (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another seller, or an unknown one, is - `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` and `state` are ids from 1 - (`0` is a 400, `null` clears them). Keys and enumerated values are trimmed and counted in characters - (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two entries that differ only by case or - accents (as the database compares them) are a 400. At most 100 entries per list, and 65,535 bytes - (UTF-8) per free-text value. Read back, `enumValues`, `attributes` and `customFields` are sorted by - key (then value, or field id), as plain strings (upper case before lower case). Requires - `items:write`. + seller's library (`POST /files`): another seller's, an unknown one, or one whose bytes were never + sent is `404 photo_not_found`. A label the seller does not have yet is created. The fields a + marketplace asks for are written with the item: `packageSizeId`, `materialIds`, `measurementLength` + and `measurementWidth` (centimeters), `enumValues` (the category's enumerated fields, `{enumKey, + enumValue}`, one entry per picked value), `attributes` (its free-text fields, `{fieldKey, value}`, + one per key) and `customFields` (the seller's own fields, `{fieldId, value}`); each list replaces + the item's whole list. A place (`purchasePlaceId`, `sellingPlaceId`) or a custom field of another + seller, or an unknown one, is `404 place_not_found` or `404 field_not_found`. `categoryId`, `sizeId` + and `state` are ids from 1 (`0` is a 400, `null` clears them). Keys and enumerated values are + trimmed and counted in characters (UTF-16 units), 1 to 100 for a key and 1 to 255 for a value; two + entries that differ only by case or accents (as the database compares them) are a 400. At most 100 + entries per list, and 65,535 bytes (UTF-8) per free-text value. Read back, `enumValues`, + `attributes` and `customFields` are sorted by key (then value, or field id), as plain strings (upper + case before lower case). Requires `items:write`. Args: id (int): diff --git a/src/controlresell/generated/models/__init__.py b/src/controlresell/generated/models/__init__.py index 609876e..e5d5f1a 100644 --- a/src/controlresell/generated/models/__init__.py +++ b/src/controlresell/generated/models/__init__.py @@ -26,6 +26,9 @@ from .favorite import Favorite from .favorite_item import FavoriteItem from .favorite_page import FavoritePage +from .file import File +from .file_upload import FileUpload +from .file_upload_request import FileUploadRequest from .item import Item from .item_attribute import ItemAttribute from .item_create_request import ItemCreateRequest @@ -96,6 +99,9 @@ "Favorite", "FavoriteItem", "FavoritePage", + "File", + "FileUpload", + "FileUploadRequest", "Item", "ItemAttribute", "ItemCreateRequest", diff --git a/src/controlresell/generated/models/file.py b/src/controlresell/generated/models/file.py new file mode 100644 index 0000000..7e58d40 --- /dev/null +++ b/src/controlresell/generated/models/file.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="File") + + +@_attrs_define +class File: + """ + Attributes: + id (UUID): + url (str): + created_at (datetime.datetime): + status (None | str | Unset): + """ + + id: UUID + url: str + created_at: datetime.datetime + status: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + url = self.url + + created_at = self.created_at.isoformat() + + status: None | str | Unset + if isinstance(self.status, Unset): + status = UNSET + else: + status = self.status + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "url": url, + "createdAt": created_at, + } + ) + if status is not UNSET: + field_dict["status"] = status + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + id = UUID(d.pop("id")) + + url = d.pop("url") + + created_at = datetime.datetime.fromisoformat(d.pop("createdAt")) + + def _parse_status(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + status = _parse_status(d.pop("status", UNSET)) + + file = cls( + id=id, + url=url, + created_at=created_at, + status=status, + ) + + file.additional_properties = d + return file + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/controlresell/generated/models/file_upload.py b/src/controlresell/generated/models/file_upload.py new file mode 100644 index 0000000..dc84b39 --- /dev/null +++ b/src/controlresell/generated/models/file_upload.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FileUpload") + + +@_attrs_define +class FileUpload: + """ + Attributes: + id (UUID): + upload_url (str): + """ + + id: UUID + upload_url: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + upload_url = self.upload_url + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "uploadUrl": upload_url, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + id = UUID(d.pop("id")) + + upload_url = d.pop("uploadUrl") + + file_upload = cls( + id=id, + upload_url=upload_url, + ) + + file_upload.additional_properties = d + return file_upload + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/controlresell/generated/models/file_upload_request.py b/src/controlresell/generated/models/file_upload_request.py new file mode 100644 index 0000000..77cb660 --- /dev/null +++ b/src/controlresell/generated/models/file_upload_request.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FileUploadRequest") + + +@_attrs_define +class FileUploadRequest: + """ + Attributes: + filename (str): + content_type (str): + size (int): + """ + + filename: str + content_type: str + size: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + filename = self.filename + + content_type = self.content_type + + size = self.size + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "filename": filename, + "contentType": content_type, + "size": size, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + filename = d.pop("filename") + + content_type = d.pop("contentType") + + size = d.pop("size") + + file_upload_request = cls( + filename=filename, + content_type=content_type, + size=size, + ) + + file_upload_request.additional_properties = d + return file_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/tests/test_client.py b/tests/test_client.py index d9c70f2..d1c6a87 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,6 +9,7 @@ ControlResellConnectionError, ControlResell, NotFoundError, + PermissionDeniedError, RateLimitError, RetryPolicy, ServerError, @@ -590,3 +591,39 @@ def unavailable_once(request): requests, sdk = client(unavailable_once) sdk.me.get() assert len(requests) == 2 + + +def test_files_register_get_and_upload(): + puts = [] + + def handler(request): + if request.method == "PUT": + puts.append(request) + return httpx.Response(503) if len(puts) == 1 else httpx.Response(200) + if request.method == "POST": + return reply({"id": "11111111-1111-1111-1111-111111111111", "uploadUrl": "https://s3.test/put", "unknownField": 1}, 201) + return reply({"id": "11111111-1111-1111-1111-111111111111", "status": "CONFIRMED", "url": "https://s3.test/get", + "createdAt": "2026-10-28T10:00:00Z"}) + + requests, sdk = client(handler) + upload = sdk.files.upload(b"abc", "a.jpg", "image/jpeg") + assert str(upload.id) == "11111111-1111-1111-1111-111111111111" + assert json.loads(requests[0].content) == {"filename": "a.jpg", "contentType": "image/jpeg", "size": 3} + assert requests[0].headers[IDEMPOTENCY_KEY_HEADER] + assert len(puts) == 2 + assert puts[1].content == b"abc" and puts[1].headers["Content-Type"] == "image/jpeg" + assert "Authorization" not in puts[1].headers + assert str(puts[1].url) == "https://s3.test/put" + assert sdk.files.get(upload.id).status == "CONFIRMED" + assert requests[-1].url.path == f"/api/public/v1/files/{upload.id}" + + +def test_files_upload_raises_a_typed_error_when_the_put_is_refused(): + def handler(request): + if request.method == "PUT": + return httpx.Response(403) + return reply({"id": "11111111-1111-1111-1111-111111111111", "uploadUrl": "https://s3.test/put"}, 201) + + _, sdk = client(handler) + with pytest.raises(PermissionDeniedError): + sdk.files.upload(b"abc", "a.jpg", "image/jpeg") From 19160187d5a60c1a0fdc3160ea4173e2457d9bb4 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Wed, 28 Oct 2026 23:21:36 +0100 Subject: [PATCH 2/2] fix: drop the unreachable transport error handler around the storage PUT --- src/controlresell/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/controlresell/client.py b/src/controlresell/client.py index 2601c47..46dafa8 100644 --- a/src/controlresell/client.py +++ b/src/controlresell/client.py @@ -502,12 +502,10 @@ def upload( upload = self.register(filename, content_type, len(data), idempotency_key=idempotency_key) if not upload.upload_url: raise ValueError(f"The API returned no upload address for file {upload.id}.") - try: - response = self._sdk._storage.put( # noqa: SLF001 - upload.upload_url, content=data, headers={"Content-Type": content_type} - ) - except httpx.TransportError as cause: - raise ControlResellConnectionError(cause) from cause + # A network failure comes out of RetryTransport as a ControlResellConnectionError. + response = self._sdk._storage.put( # noqa: SLF001 + upload.upload_url, content=data, headers={"Content-Type": content_type} + ) if not response.is_success: raise error_of(response.status_code, None, response.headers) return upload