Skip to content

Commit bac58bf

Browse files
authored
Feature/image processing (#19)
* Add image processing service * Rename action parameter to _action in _service_request(..) * Add Portrait strategy class * Add Landscape strategy class * Add Auto strategy class * Add Fit strategy class * Add Crop strategy class * Create CropModeMixin * Use CropModeMixin with Fit strategy * Add Square strategy * Add Fill strategy
1 parent 2d3d62f commit bac58bf

26 files changed

+635
-16
lines changed

src/abstract_api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .exchange_rates import ExchangeRates
77
from .holidays import Holidays
88
from .iban_validation import IBANValidation
9+
from .image_processing import ImageProcessing
910
from .ip_geolocation import IPGeolocation
1011
from .phone_validation import PhoneValidation
1112
from .timezone import Timezone
@@ -20,6 +21,7 @@
2021
"ExchangeRates",
2122
"Holidays",
2223
"IBANValidation",
24+
"ImageProcessing",
2325
"IPGeolocation",
2426
"PhoneValidation",
2527
"Timezone",

src/abstract_api/bases/base_service.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from abc import ABC
2-
from typing import Final
2+
from io import BytesIO
3+
from typing import Any, Final
34

45
import requests
56
from requests import codes
67

7-
from abstract_api.exceptions import APIRequestError
8+
from abstract_api.exceptions import APIRequestError, ClientRequestError
89

910

1011
class BaseService(ABC):
@@ -42,24 +43,47 @@ def _service_url(self, action: str = "") -> str:
4243

4344
def _service_request(
4445
self,
45-
action: str = "",
46+
_method: str = "GET",
47+
_body: dict[str, Any] | None = None,
48+
_files: dict[str, BytesIO] | None = None,
49+
_action: str = "",
4650
**params
4751
) -> requests.models.Response:
4852
"""Makes the HTTP call to Abstract API service endpoint.
4953
5054
Args:
51-
action: Action to be performed using the service.
55+
_method: HTTP method to use.
56+
_body: Request body.
57+
_files: Files to be attached to the request body (uploading files).
58+
_action: Action to be performed using the service.
5259
Only for services that have it (i.e. VAT).
5360
params: The URL parameter that should be used when calling the API
5461
endpoints.
5562
5663
Returns:
5764
AbstractAPI's response.
5865
"""
59-
response = requests.get(
60-
self._service_url(action),
61-
params={"api_key": self._api_key} | params
62-
)
66+
if _method.lower() not in ["get", "post"]:
67+
raise ClientRequestError(
68+
f"Invalid or not allowed HTTP method '{_method}'"
69+
)
70+
71+
request_kwargs: dict[str, Any] = {
72+
"method": _method,
73+
"url": self._service_url(_action)
74+
}
75+
76+
_method = _method.lower()
77+
if _method == "get":
78+
request_kwargs["params"] = {"api_key": self._api_key} | params
79+
else:
80+
if _files:
81+
request_kwargs["files"] = _files
82+
if _body:
83+
request_kwargs["json"] = _body
84+
85+
response = requests.request(**request_kwargs)
86+
6387
if response.status_code not in [codes.OK, codes.NO_CONTENT]:
6488
APIRequestError.raise_from_response(response)
6589
return response

src/abstract_api/exchange_rates/exchange_rates.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def live(
5757
VATValidationResponse representing API call response.
5858
"""
5959
response = self._service_request(
60-
action="live",
60+
_action="live",
6161
base=base,
6262
target=self._target_as_param(target)
6363
)
@@ -100,7 +100,7 @@ def convert(
100100
ExchangeRatesConversionResponse representing API call response.
101101
"""
102102
response = self._service_request(
103-
action="convert",
103+
_action="convert",
104104
base=base,
105105
target=target,
106106
date=date,
@@ -141,7 +141,7 @@ def historical(
141141
HistoricalExchangeRatesResponse representing API call response.
142142
"""
143143
response = self._service_request(
144-
action="historical",
144+
_action="historical",
145145
base=base,
146146
target=self._target_as_param(target),
147147
date=date
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import Final
2+
3+
from .image_processing import ImageProcessing
4+
from .image_processing_response import ImageProcessingResponse
5+
6+
__all__: Final[list[str]] = [
7+
"ImageProcessing",
8+
"ImageProcessingResponse"
9+
]
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import json
2+
from typing import Any, BinaryIO
3+
4+
from abstract_api.bases import BaseService
5+
from abstract_api.exceptions import ClientRequestError, ResponseParseError
6+
7+
from .image_processing_response import ImageProcessingResponse
8+
from .strategies import BaseStrategy
9+
10+
11+
class ImageProcessing(BaseService):
12+
"""AbstractAPI image processing service.
13+
14+
Used to convert, compress, or optimize an image.
15+
16+
Attributes:
17+
_subdomain: Image processing service subdomain.
18+
"""
19+
_subdomain: str = "images"
20+
21+
def upload(
22+
self,
23+
image: BinaryIO,
24+
lossy: bool | None = None,
25+
quality: int | None = None,
26+
resize: BaseStrategy | None = None
27+
) -> ImageProcessingResponse:
28+
"""Can convert, compress, or optimize an image.
29+
30+
Args:
31+
image: The image to be processed, it should be a file-like or a
32+
file opened in binary reading mode.
33+
lossy: If True, the API will perform a lossy compression on the
34+
image, reducing the size massively with a small drop in
35+
image quality. If False, the image size will only be reduced
36+
slightly (10% - 20% at most), but there will be no reduction
37+
in image quality. The default value is False if this is not
38+
provided.
39+
quality: This is an integer between 0 and 100 that determines
40+
the quality level for lossy compression. If not submitted
41+
it will be determined intelligently by AbstractAPI.
42+
Generally a quality above 95 is useless and may result in
43+
an image that is larger than the input image, and a quality
44+
below 25 will result in an image so low in quality that it
45+
will be useless.
46+
resize: This is an instance of BaseStrategy subclasses that
47+
specifies how to resize the image. If not provided, we will
48+
only compress the image as desired.
49+
50+
Returns:
51+
ImageProcessingResponse representing API call response.
52+
"""
53+
return self._process(
54+
image=image,
55+
lossy=lossy,
56+
quality=quality,
57+
resize=resize
58+
)
59+
60+
def url(
61+
self,
62+
url: str,
63+
lossy: bool | None = None,
64+
quality: int | None = None,
65+
resize: BaseStrategy | None = None
66+
) -> ImageProcessingResponse:
67+
"""Can convert, compress, or optimize an image in the given URL.
68+
69+
Args:
70+
url: The URL of the image that you would like to edit.
71+
Note that is cannot be more than 32 MB in size.
72+
lossy: If True, the API will perform a lossy compression on the
73+
image, reducing the size massively with a small drop in
74+
image quality. If False, the image size will only be reduced
75+
slightly (10% - 20% at most), but there will be no reduction
76+
in image quality. The default value is False if this is not
77+
provided.
78+
quality: This is an integer between 0 and 100 that determines
79+
the quality level for lossy compression. If not submitted
80+
it will be determined intelligently by AbstractAPI.
81+
Generally a quality above 95 is useless and may result in
82+
an image that is larger than the input image, and a quality
83+
below 25 will result in an image so low in quality that it
84+
will be useless.
85+
resize: This is an instance of BaseStrategy subclasses that
86+
specifies how to resize the image. If not provided, we will
87+
only compress the image as desired.
88+
89+
Returns:
90+
ImageProcessingResponse representing API call response.
91+
"""
92+
return self._process(
93+
url=url,
94+
lossy=lossy,
95+
quality=quality,
96+
resize=resize
97+
)
98+
99+
def _process(
100+
self,
101+
image: BinaryIO | None = None,
102+
url: str | None = None,
103+
lossy: bool | None = None,
104+
quality: int | None = None,
105+
resize: BaseStrategy | None = None
106+
) -> ImageProcessingResponse:
107+
108+
if image is None and url is None:
109+
raise ClientRequestError("Image or URL must be passed")
110+
111+
data: dict[str, Any] = {"api_key": self._api_key}
112+
if resize is not None:
113+
data["resize"] = resize.json()
114+
if lossy is not None:
115+
data["lossy"] = lossy
116+
if quality is not None:
117+
data["quality"] = quality
118+
119+
action = "upload/" if image is not None else "url/"
120+
service_kwargs: dict[str, Any] = {
121+
"_action": action,
122+
"_method": "POST"
123+
}
124+
if action == "upload/":
125+
service_kwargs["_files"] = {
126+
"image": image,
127+
"data": (None, json.dumps(data))
128+
}
129+
else:
130+
service_kwargs["_body"] = data | {"url": url}
131+
132+
response = self._service_request(**service_kwargs)
133+
134+
try:
135+
image_processing_response = ImageProcessingResponse(
136+
response=response
137+
)
138+
except Exception as e:
139+
raise ResponseParseError(
140+
"Failed to parse response as ImageProcessingResponse"
141+
) from e
142+
143+
return image_processing_response
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from typing import TYPE_CHECKING
2+
3+
import requests
4+
5+
from abstract_api.bases import JSONResponse
6+
7+
from .response_fields import RESPONSE_FIELDS
8+
9+
10+
class ImageProcessingResponse(JSONResponse):
11+
"""Image processing service response."""
12+
def __init__(
13+
self,
14+
response: requests.models.Response
15+
) -> None:
16+
"""Initializes a new ImageProcessingResponse."""
17+
super().__init__(response)
18+
self._response_fields = RESPONSE_FIELDS
19+
not_in_response = object()
20+
for field in RESPONSE_FIELDS:
21+
if TYPE_CHECKING:
22+
assert isinstance(self.meta.body_json, dict)
23+
value = self.meta.body_json.get(field, not_in_response)
24+
# Set property only if field was returned
25+
if value is not not_in_response:
26+
setattr(self, f"_{field}", value)
27+
28+
@property
29+
def original_size(self) -> str | None:
30+
"""The original size of the provided image, in bytes."""
31+
return self._get_response_field("original_size")
32+
33+
@property
34+
def original_height(self) -> str | None:
35+
"""The original height of the provided image, in bytes."""
36+
return self._get_response_field("original_height")
37+
38+
@property
39+
def original_width(self) -> str | None:
40+
"""The original width of the provided image, in bytes."""
41+
return self._get_response_field("original_width")
42+
43+
@property
44+
def final_size(self) -> str | None:
45+
"""The final size of the processed image, in bytes."""
46+
return self._get_response_field("final_size")
47+
48+
@property
49+
def bytes_saved(self) -> str | None:
50+
"""The number of bytes saved by optimizing the image, in bytes."""
51+
return self._get_response_field("bytes_saved")
52+
53+
@property
54+
def final_height(self) -> str | None:
55+
"""The final height of the processed image, in bytes."""
56+
return self._get_response_field("final_height")
57+
58+
@property
59+
def final_width(self) -> str | None:
60+
"""The final width of the processed image, in bytes."""
61+
return self._get_response_field("final_width")
62+
63+
@property
64+
def url(self) -> str | None:
65+
"""The URL with the new processed image."""
66+
return self._get_response_field("url")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Response fields of image processing service endpoints."""
2+
3+
4+
RESPONSE_FIELDS: frozenset[str] = frozenset({
5+
"original_size",
6+
"original_height",
7+
"original_width",
8+
"final_size",
9+
"bytes_saved",
10+
"final_height",
11+
"final_width",
12+
"url"
13+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Final
2+
3+
from .auto import Auto
4+
from .base_strategy import BaseStrategy
5+
from .crop import Crop
6+
from .exact import Exact
7+
from .fill import Fill
8+
from .fit import Fit
9+
from .landscape import Landscape
10+
from .portrait import Portrait
11+
from .square import Square
12+
13+
__all__: Final[list[str]] = [
14+
"Auto",
15+
"BaseStrategy",
16+
"Crop",
17+
"Exact",
18+
"Fill",
19+
"Fit",
20+
"Landscape",
21+
"Portrait",
22+
"Square"
23+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import Final
2+
3+
from .crop_mode_mixin import CropModeMixin
4+
from .height_mixin import HeightMixin
5+
from .width_mixin import WidthMixin
6+
7+
__all__: Final[list[str]] = [
8+
"CropModeMixin",
9+
"HeightMixin",
10+
"WidthMixin"
11+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Protocol
2+
3+
4+
class JSONRepresentableProtocol(Protocol):
5+
"""MyPy protocol to indicate a class having .json() method."""
6+
def json(self) -> dict[str, int | str]: # noqa: D102
7+
...

0 commit comments

Comments
 (0)