pexafy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pexafy/__init__.py +61 -0
- pexafy/cli.py +89 -0
- pexafy/client.py +447 -0
- pexafy/errors.py +102 -0
- pexafy/models.py +237 -0
- pexafy-0.1.0.dist-info/METADATA +191 -0
- pexafy-0.1.0.dist-info/RECORD +10 -0
- pexafy-0.1.0.dist-info/WHEEL +4 -0
- pexafy-0.1.0.dist-info/entry_points.txt +2 -0
- pexafy-0.1.0.dist-info/licenses/LICENSE +21 -0
pexafy/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Python client for the Pexafy image search API.
|
|
2
|
+
|
|
3
|
+
from pexafy import Client
|
|
4
|
+
|
|
5
|
+
client = Client("your-api-key")
|
|
6
|
+
for photo in client.search("a quiet street in the rain", per_page=10):
|
|
7
|
+
print(photo.urls.regular)
|
|
8
|
+
|
|
9
|
+
Get a key at https://pexafy.com — the free tier does not need a card.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .client import DEFAULT_BASE_URL, AsyncClient, Client
|
|
15
|
+
from .errors import (
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ConnectionError_,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionError_,
|
|
21
|
+
PexafyError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServerError,
|
|
24
|
+
TimeoutError_,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from .models import (
|
|
28
|
+
Attribution,
|
|
29
|
+
Collection,
|
|
30
|
+
CollectionItem,
|
|
31
|
+
Pagination,
|
|
32
|
+
Photo,
|
|
33
|
+
Photographer,
|
|
34
|
+
PhotoUrls,
|
|
35
|
+
SearchResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Client",
|
|
40
|
+
"AsyncClient",
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"Photo",
|
|
43
|
+
"PhotoUrls",
|
|
44
|
+
"Attribution",
|
|
45
|
+
"Photographer",
|
|
46
|
+
"Collection",
|
|
47
|
+
"CollectionItem",
|
|
48
|
+
"Pagination",
|
|
49
|
+
"SearchResult",
|
|
50
|
+
"PexafyError",
|
|
51
|
+
"APIError",
|
|
52
|
+
"AuthenticationError",
|
|
53
|
+
"PermissionError_",
|
|
54
|
+
"NotFoundError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"ValidationError",
|
|
57
|
+
"ServerError",
|
|
58
|
+
"TimeoutError_",
|
|
59
|
+
"ConnectionError_",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
pexafy/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
pexafy search "morning fog over pine trees" -n 5
|
|
4
|
+
pexafy photo 019e0eb8-b028-73cb-9296-dfa70f557bc9
|
|
5
|
+
pexafy similar 019e0eb8-b028-73cb-9296-dfa70f557bc9
|
|
6
|
+
pexafy usage
|
|
7
|
+
|
|
8
|
+
Reads the key from PEXAFY_API_KEY.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import __version__, errors
|
|
19
|
+
from .client import Client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_photos(photos: list[Any], as_json: bool) -> None:
|
|
23
|
+
if as_json:
|
|
24
|
+
json.dump([p.raw for p in photos], sys.stdout, indent=2)
|
|
25
|
+
sys.stdout.write("\n")
|
|
26
|
+
return
|
|
27
|
+
for p in photos:
|
|
28
|
+
score = f"{p.relevance_score:.3f}" if p.relevance_score is not None else " - "
|
|
29
|
+
size = f"{p.width}x{p.height}" if p.width else ""
|
|
30
|
+
print(
|
|
31
|
+
f"{score} {p.photo_id} {size:>11} "
|
|
32
|
+
f"{p.color_name:<10} {p.source:<10} {p.urls.regular}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="pexafy", description="Search stock photos from the terminal."
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--version", action="version", version=f"pexafy {__version__}")
|
|
41
|
+
parser.add_argument("--json", action="store_true", help="raw JSON instead of a table")
|
|
42
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
s = sub.add_parser("search", help="search by description")
|
|
45
|
+
s.add_argument("query")
|
|
46
|
+
s.add_argument("-n", "--per-page", type=int, default=10)
|
|
47
|
+
s.add_argument("--color")
|
|
48
|
+
s.add_argument("--orientation")
|
|
49
|
+
s.add_argument("--source")
|
|
50
|
+
|
|
51
|
+
p = sub.add_parser("photo", help="fetch one photo")
|
|
52
|
+
p.add_argument("photo_id")
|
|
53
|
+
|
|
54
|
+
sim = sub.add_parser("similar", help="photos that look like this one")
|
|
55
|
+
sim.add_argument("photo_id")
|
|
56
|
+
sim.add_argument("-n", "--per-page", type=int, default=10)
|
|
57
|
+
|
|
58
|
+
sub.add_parser("usage", help="current month usage")
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with Client() as client:
|
|
64
|
+
if args.command == "search":
|
|
65
|
+
result = client.search(
|
|
66
|
+
args.query,
|
|
67
|
+
per_page=args.per_page,
|
|
68
|
+
color_name=args.color,
|
|
69
|
+
orientation=args.orientation,
|
|
70
|
+
source=args.source,
|
|
71
|
+
)
|
|
72
|
+
_print_photos(result.photos, args.json)
|
|
73
|
+
elif args.command == "photo":
|
|
74
|
+
photo = client.get_photo(args.photo_id)
|
|
75
|
+
_print_photos([photo], args.json)
|
|
76
|
+
elif args.command == "similar":
|
|
77
|
+
result = client.similar(args.photo_id, per_page=args.per_page)
|
|
78
|
+
_print_photos(result.photos, args.json)
|
|
79
|
+
elif args.command == "usage":
|
|
80
|
+
json.dump(client.usage(), sys.stdout, indent=2)
|
|
81
|
+
sys.stdout.write("\n")
|
|
82
|
+
except errors.PexafyError as exc:
|
|
83
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
pexafy/client.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Synchronous and asynchronous clients for the Pexafy API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, BinaryIO, Optional, Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from . import errors
|
|
15
|
+
from .models import (
|
|
16
|
+
Collection,
|
|
17
|
+
CollectionItem,
|
|
18
|
+
Pagination,
|
|
19
|
+
Photo,
|
|
20
|
+
Photographer,
|
|
21
|
+
SearchResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = ["Client", "AsyncClient", "DEFAULT_BASE_URL"]
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.pexafy.com"
|
|
27
|
+
DEFAULT_TIMEOUT = 30.0
|
|
28
|
+
DEFAULT_RETRIES = 2
|
|
29
|
+
RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
30
|
+
|
|
31
|
+
ImageInput = Union[str, Path, bytes, BinaryIO]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _csv(value: Union[str, Sequence[str], None]) -> Optional[str]:
|
|
35
|
+
"""The API takes repeated filters as a comma separated list."""
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
return value
|
|
40
|
+
return ",".join(str(v) for v in value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean(params: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _open_image(image: ImageInput) -> tuple[str, Any]:
|
|
48
|
+
"""Normalise the several things a caller might hand us into a file tuple."""
|
|
49
|
+
if isinstance(image, (str, Path)):
|
|
50
|
+
path = Path(image)
|
|
51
|
+
return path.name, path.read_bytes()
|
|
52
|
+
if isinstance(image, bytes):
|
|
53
|
+
return "query.jpg", image
|
|
54
|
+
name = getattr(image, "name", "query.jpg")
|
|
55
|
+
return Path(str(name)).name, image.read()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Base:
|
|
59
|
+
"""Everything that does not touch the network."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: Optional[str] = None,
|
|
64
|
+
*,
|
|
65
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
66
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
67
|
+
max_retries: int = DEFAULT_RETRIES,
|
|
68
|
+
) -> None:
|
|
69
|
+
key = api_key or os.environ.get("PEXAFY_API_KEY")
|
|
70
|
+
if not key:
|
|
71
|
+
raise errors.PexafyError(
|
|
72
|
+
"No API key. Pass api_key= or set PEXAFY_API_KEY. "
|
|
73
|
+
"Keys are created at https://pexafy.com/dashboard/"
|
|
74
|
+
)
|
|
75
|
+
self.api_key = key
|
|
76
|
+
self.base_url = base_url.rstrip("/")
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.max_retries = max_retries
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def _headers(self) -> dict[str, str]:
|
|
82
|
+
from . import __version__
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"x-api-key": self.api_key,
|
|
86
|
+
"accept": "application/json",
|
|
87
|
+
"user-agent": f"pexafy-python/{__version__}",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _url(self, path: str) -> str:
|
|
91
|
+
return f"{self.base_url}/api/v1{path}"
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _retry_delay(attempt: int, response: Optional[httpx.Response]) -> float:
|
|
95
|
+
"""Honour Retry-After when the server sends one, back off otherwise."""
|
|
96
|
+
if response is not None:
|
|
97
|
+
header = response.headers.get("retry-after")
|
|
98
|
+
if header:
|
|
99
|
+
try:
|
|
100
|
+
return min(float(header), 60.0)
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
return min(2.0**attempt, 30.0) + random.random() * 0.3
|
|
104
|
+
|
|
105
|
+
def _unwrap(self, response: httpx.Response) -> dict[str, Any]:
|
|
106
|
+
"""Turn the response envelope into data, or raise the right error."""
|
|
107
|
+
try:
|
|
108
|
+
body = response.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
body = {}
|
|
111
|
+
|
|
112
|
+
if response.is_success and body.get("success", True):
|
|
113
|
+
return body
|
|
114
|
+
|
|
115
|
+
err = body.get("error") or {}
|
|
116
|
+
message = (
|
|
117
|
+
err.get("message") or body.get("detail")
|
|
118
|
+
or response.reason_phrase or "request failed"
|
|
119
|
+
)
|
|
120
|
+
if isinstance(message, list): # FastAPI validation detail
|
|
121
|
+
message = "; ".join(str(m.get("msg", m)) for m in message)
|
|
122
|
+
|
|
123
|
+
exc_class = errors.from_status(response.status_code)
|
|
124
|
+
kwargs: dict[str, Any] = {
|
|
125
|
+
"status_code": response.status_code,
|
|
126
|
+
"code": err.get("code"),
|
|
127
|
+
"request_id": err.get("request_id") or (body.get("meta") or {}).get("request_id"),
|
|
128
|
+
"payload": body,
|
|
129
|
+
}
|
|
130
|
+
if exc_class is errors.RateLimitError:
|
|
131
|
+
retry_after = response.headers.get("retry-after")
|
|
132
|
+
kwargs["retry_after"] = float(retry_after) if retry_after else None
|
|
133
|
+
raise exc_class(str(message), **kwargs)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _to_search_result(body: dict[str, Any]) -> SearchResult:
|
|
137
|
+
meta = body.get("meta") or {}
|
|
138
|
+
return SearchResult(
|
|
139
|
+
photos=[Photo.from_dict(p) for p in body.get("data") or []],
|
|
140
|
+
pagination=Pagination.from_dict(body.get("pagination") or {}),
|
|
141
|
+
request_id=meta.get("request_id", ""),
|
|
142
|
+
took_ms=meta.get("took_ms"),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _search_params(
|
|
146
|
+
self,
|
|
147
|
+
q: Optional[str],
|
|
148
|
+
*,
|
|
149
|
+
color_name=None, color_hex=None, color_tolerance=None,
|
|
150
|
+
orientation=None, source=None, license_type=None, photographer=None,
|
|
151
|
+
per_page=None, limit=None, score_threshold=None, cursor=None,
|
|
152
|
+
fields=None, after_date=None, sort_by=None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
return _clean({
|
|
155
|
+
"q": q,
|
|
156
|
+
"color_name": _csv(color_name),
|
|
157
|
+
"color_hex": color_hex,
|
|
158
|
+
"color_tolerance": color_tolerance,
|
|
159
|
+
"orientation": _csv(orientation),
|
|
160
|
+
"source": _csv(source),
|
|
161
|
+
"license_type": _csv(license_type),
|
|
162
|
+
"photographer": photographer,
|
|
163
|
+
"per_page": per_page,
|
|
164
|
+
"limit": limit,
|
|
165
|
+
"score_threshold": score_threshold,
|
|
166
|
+
"cursor": cursor,
|
|
167
|
+
"fields": _csv(fields),
|
|
168
|
+
"after_date": str(after_date) if after_date else None,
|
|
169
|
+
"sort_by": sort_by,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Client(_Base):
|
|
174
|
+
"""Blocking client.
|
|
175
|
+
|
|
176
|
+
>>> from pexafy import Client
|
|
177
|
+
>>> client = Client("your-api-key")
|
|
178
|
+
>>> for photo in client.search("sunrise over a foggy valley", per_page=5):
|
|
179
|
+
... print(photo.photo_id, photo.urls.regular)
|
|
180
|
+
|
|
181
|
+
Safe to keep around for the lifetime of your process; it holds one
|
|
182
|
+
connection pool. Close it when you are done, or use it as a context
|
|
183
|
+
manager.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, api_key: Optional[str] = None, **kwargs: Any) -> None:
|
|
187
|
+
super().__init__(api_key, **kwargs)
|
|
188
|
+
self._http = httpx.Client(timeout=self.timeout, headers=self._headers)
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> Client:
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc: Any) -> None:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
self._http.close()
|
|
198
|
+
|
|
199
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
200
|
+
url = self._url(path)
|
|
201
|
+
last_response: Optional[httpx.Response] = None
|
|
202
|
+
for attempt in range(self.max_retries + 1):
|
|
203
|
+
try:
|
|
204
|
+
response = self._http.request(method, url, **kwargs)
|
|
205
|
+
except httpx.TimeoutException as exc:
|
|
206
|
+
if attempt >= self.max_retries:
|
|
207
|
+
raise errors.TimeoutError_(
|
|
208
|
+
f"{method} {path} timed out after {self.timeout}s"
|
|
209
|
+
) from exc
|
|
210
|
+
time.sleep(self._retry_delay(attempt, None))
|
|
211
|
+
continue
|
|
212
|
+
except httpx.TransportError as exc:
|
|
213
|
+
if attempt >= self.max_retries:
|
|
214
|
+
raise errors.ConnectionError_(
|
|
215
|
+
f"could not reach {self.base_url}: {exc}"
|
|
216
|
+
) from exc
|
|
217
|
+
time.sleep(self._retry_delay(attempt, None))
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
if response.status_code in RETRY_STATUS and attempt < self.max_retries:
|
|
221
|
+
last_response = response
|
|
222
|
+
time.sleep(self._retry_delay(attempt, response))
|
|
223
|
+
continue
|
|
224
|
+
return self._unwrap(response)
|
|
225
|
+
|
|
226
|
+
return self._unwrap(last_response) # pragma: no cover - loop always returns
|
|
227
|
+
|
|
228
|
+
# -- search ---------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def search(self, q: str, **filters: Any) -> SearchResult:
|
|
231
|
+
"""Search by description.
|
|
232
|
+
|
|
233
|
+
Write what you would say to a person: `two people hiking on a ridge at
|
|
234
|
+
dawn` works better than `hiking dawn`. The engine matches meaning, so
|
|
235
|
+
keyword stuffing makes results worse, not better.
|
|
236
|
+
"""
|
|
237
|
+
params = self._search_params(q, **filters)
|
|
238
|
+
return self._to_search_result(self._request("GET", "/search/photos", params=params))
|
|
239
|
+
|
|
240
|
+
def iter_search(
|
|
241
|
+
self, q: str, *, max_results: Optional[int] = None, **filters: Any
|
|
242
|
+
) -> Iterator[Photo]:
|
|
243
|
+
"""Walk every page, following the cursor for you."""
|
|
244
|
+
seen = 0
|
|
245
|
+
cursor = filters.pop("cursor", None)
|
|
246
|
+
while True:
|
|
247
|
+
page = self.search(q, cursor=cursor, **filters)
|
|
248
|
+
for photo in page.photos:
|
|
249
|
+
yield photo
|
|
250
|
+
seen += 1
|
|
251
|
+
if max_results is not None and seen >= max_results:
|
|
252
|
+
return
|
|
253
|
+
if not page.has_more or not page.next_cursor:
|
|
254
|
+
return
|
|
255
|
+
cursor = page.next_cursor
|
|
256
|
+
|
|
257
|
+
def search_by_image(self, image: ImageInput, **filters: Any) -> SearchResult:
|
|
258
|
+
"""Find photos that look like the one you pass in.
|
|
259
|
+
|
|
260
|
+
Accepts a path, raw bytes, or an open binary file.
|
|
261
|
+
"""
|
|
262
|
+
filename, content = _open_image(image)
|
|
263
|
+
params = self._search_params(None, **filters)
|
|
264
|
+
body = self._request(
|
|
265
|
+
"POST", "/search/photos",
|
|
266
|
+
files={"image": (filename, content)},
|
|
267
|
+
params=params,
|
|
268
|
+
)
|
|
269
|
+
return self._to_search_result(body)
|
|
270
|
+
|
|
271
|
+
# -- photos ---------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def get_photo(self, photo_id: str) -> Photo:
|
|
274
|
+
return Photo.from_dict(self._request("GET", f"/photos/{photo_id}")["data"])
|
|
275
|
+
|
|
276
|
+
def similar(self, photo_id: str, **filters: Any) -> SearchResult:
|
|
277
|
+
params = self._search_params(None, **filters)
|
|
278
|
+
return self._to_search_result(
|
|
279
|
+
self._request("GET", f"/photos/{photo_id}/similar", params=params)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# -- facets ---------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def colors(self) -> list[dict[str, Any]]:
|
|
285
|
+
return self._request("GET", "/facets/colors")["data"]
|
|
286
|
+
|
|
287
|
+
def sources(self) -> list[dict[str, Any]]:
|
|
288
|
+
return self._request("GET", "/facets/sources")["data"]
|
|
289
|
+
|
|
290
|
+
def orientations(self) -> list[dict[str, Any]]:
|
|
291
|
+
return self._request("GET", "/facets/orientations")["data"]
|
|
292
|
+
|
|
293
|
+
def licenses(self) -> list[dict[str, Any]]:
|
|
294
|
+
return self._request("GET", "/facets/licenses")["data"]
|
|
295
|
+
|
|
296
|
+
def suggest_photographers(self, q: str, *, limit: Optional[int] = None) -> list[Photographer]:
|
|
297
|
+
params = _clean({"q": q, "limit": limit})
|
|
298
|
+
data = self._request("GET", "/facets/photographers/suggest", params=params)["data"]
|
|
299
|
+
return [Photographer.from_dict(p) for p in data]
|
|
300
|
+
|
|
301
|
+
def photographer(self, username: str) -> list[Photographer]:
|
|
302
|
+
data = self._request("GET", f"/facets/photographers/{username}")["data"]
|
|
303
|
+
return [Photographer.from_dict(p) for p in data]
|
|
304
|
+
|
|
305
|
+
# -- collections ----------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def collections(self) -> list[Collection]:
|
|
308
|
+
return [Collection.from_dict(c) for c in self._request("GET", "/collections")["data"]]
|
|
309
|
+
|
|
310
|
+
def create_collection(
|
|
311
|
+
self, name: str, *, description: Optional[str] = None, is_public: bool = False
|
|
312
|
+
) -> Collection:
|
|
313
|
+
body = _clean({"name": name, "description": description, "is_public": is_public})
|
|
314
|
+
return Collection.from_dict(self._request("POST", "/collections", json=body)["data"])
|
|
315
|
+
|
|
316
|
+
def collection(self, collection_id: int) -> Collection:
|
|
317
|
+
return Collection.from_dict(self._request("GET", f"/collections/{collection_id}")["data"])
|
|
318
|
+
|
|
319
|
+
def delete_collection(self, collection_id: int) -> None:
|
|
320
|
+
self._request("DELETE", f"/collections/{collection_id}")
|
|
321
|
+
|
|
322
|
+
def add_to_collection(self, collection_id: int, photo_id: str) -> CollectionItem:
|
|
323
|
+
body = self._request(
|
|
324
|
+
"POST", f"/collections/{collection_id}/photos", json={"photo_id": photo_id}
|
|
325
|
+
)
|
|
326
|
+
return CollectionItem.from_dict(body["data"])
|
|
327
|
+
|
|
328
|
+
def remove_from_collection(self, collection_id: int, photo_id: str) -> None:
|
|
329
|
+
self._request("DELETE", f"/collections/{collection_id}/photos/{photo_id}")
|
|
330
|
+
|
|
331
|
+
# -- usage ----------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def usage(self) -> dict[str, Any]:
|
|
334
|
+
return self._request("GET", "/usage")["data"]
|
|
335
|
+
|
|
336
|
+
def usage_daily(self) -> dict[str, Any]:
|
|
337
|
+
return self._request("GET", "/usage/daily")["data"]
|
|
338
|
+
|
|
339
|
+
def usage_monthly(self) -> dict[str, Any]:
|
|
340
|
+
return self._request("GET", "/usage/monthly")["data"]
|
|
341
|
+
|
|
342
|
+
def usage_by_key(self) -> dict[str, Any]:
|
|
343
|
+
return self._request("GET", "/usage/by-key")["data"]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class AsyncClient(_Base):
|
|
347
|
+
"""Same surface as :class:`Client`, awaitable.
|
|
348
|
+
|
|
349
|
+
>>> async with AsyncClient() as client:
|
|
350
|
+
... page = await client.search("empty office at night")
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(self, api_key: Optional[str] = None, **kwargs: Any) -> None:
|
|
354
|
+
super().__init__(api_key, **kwargs)
|
|
355
|
+
self._http = httpx.AsyncClient(timeout=self.timeout, headers=self._headers)
|
|
356
|
+
|
|
357
|
+
async def __aenter__(self) -> AsyncClient:
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
361
|
+
await self.aclose()
|
|
362
|
+
|
|
363
|
+
async def aclose(self) -> None:
|
|
364
|
+
await self._http.aclose()
|
|
365
|
+
|
|
366
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
367
|
+
import asyncio
|
|
368
|
+
|
|
369
|
+
url = self._url(path)
|
|
370
|
+
last_response: Optional[httpx.Response] = None
|
|
371
|
+
for attempt in range(self.max_retries + 1):
|
|
372
|
+
try:
|
|
373
|
+
response = await self._http.request(method, url, **kwargs)
|
|
374
|
+
except httpx.TimeoutException as exc:
|
|
375
|
+
if attempt >= self.max_retries:
|
|
376
|
+
raise errors.TimeoutError_(
|
|
377
|
+
f"{method} {path} timed out after {self.timeout}s"
|
|
378
|
+
) from exc
|
|
379
|
+
await asyncio.sleep(self._retry_delay(attempt, None))
|
|
380
|
+
continue
|
|
381
|
+
except httpx.TransportError as exc:
|
|
382
|
+
if attempt >= self.max_retries:
|
|
383
|
+
raise errors.ConnectionError_(
|
|
384
|
+
f"could not reach {self.base_url}: {exc}"
|
|
385
|
+
) from exc
|
|
386
|
+
await asyncio.sleep(self._retry_delay(attempt, None))
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
if response.status_code in RETRY_STATUS and attempt < self.max_retries:
|
|
390
|
+
last_response = response
|
|
391
|
+
await asyncio.sleep(self._retry_delay(attempt, response))
|
|
392
|
+
continue
|
|
393
|
+
return self._unwrap(response)
|
|
394
|
+
|
|
395
|
+
return self._unwrap(last_response) # pragma: no cover
|
|
396
|
+
|
|
397
|
+
async def search(self, q: str, **filters: Any) -> SearchResult:
|
|
398
|
+
params = self._search_params(q, **filters)
|
|
399
|
+
return self._to_search_result(await self._request("GET", "/search/photos", params=params))
|
|
400
|
+
|
|
401
|
+
async def iter_search(
|
|
402
|
+
self, q: str, *, max_results: Optional[int] = None, **filters: Any
|
|
403
|
+
) -> AsyncIterator[Photo]:
|
|
404
|
+
seen = 0
|
|
405
|
+
cursor = filters.pop("cursor", None)
|
|
406
|
+
while True:
|
|
407
|
+
page = await self.search(q, cursor=cursor, **filters)
|
|
408
|
+
for photo in page.photos:
|
|
409
|
+
yield photo
|
|
410
|
+
seen += 1
|
|
411
|
+
if max_results is not None and seen >= max_results:
|
|
412
|
+
return
|
|
413
|
+
if not page.has_more or not page.next_cursor:
|
|
414
|
+
return
|
|
415
|
+
cursor = page.next_cursor
|
|
416
|
+
|
|
417
|
+
async def search_by_image(self, image: ImageInput, **filters: Any) -> SearchResult:
|
|
418
|
+
filename, content = _open_image(image)
|
|
419
|
+
params = self._search_params(None, **filters)
|
|
420
|
+
body = await self._request(
|
|
421
|
+
"POST", "/search/photos", files={"image": (filename, content)}, params=params
|
|
422
|
+
)
|
|
423
|
+
return self._to_search_result(body)
|
|
424
|
+
|
|
425
|
+
async def get_photo(self, photo_id: str) -> Photo:
|
|
426
|
+
body = await self._request("GET", f"/photos/{photo_id}")
|
|
427
|
+
return Photo.from_dict(body["data"])
|
|
428
|
+
|
|
429
|
+
async def similar(self, photo_id: str, **filters: Any) -> SearchResult:
|
|
430
|
+
params = self._search_params(None, **filters)
|
|
431
|
+
body = await self._request("GET", f"/photos/{photo_id}/similar", params=params)
|
|
432
|
+
return self._to_search_result(body)
|
|
433
|
+
|
|
434
|
+
async def colors(self) -> list[dict[str, Any]]:
|
|
435
|
+
return (await self._request("GET", "/facets/colors"))["data"]
|
|
436
|
+
|
|
437
|
+
async def sources(self) -> list[dict[str, Any]]:
|
|
438
|
+
return (await self._request("GET", "/facets/sources"))["data"]
|
|
439
|
+
|
|
440
|
+
async def orientations(self) -> list[dict[str, Any]]:
|
|
441
|
+
return (await self._request("GET", "/facets/orientations"))["data"]
|
|
442
|
+
|
|
443
|
+
async def licenses(self) -> list[dict[str, Any]]:
|
|
444
|
+
return (await self._request("GET", "/facets/licenses"))["data"]
|
|
445
|
+
|
|
446
|
+
async def usage(self) -> dict[str, Any]:
|
|
447
|
+
return (await self._request("GET", "/usage"))["data"]
|
pexafy/errors.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Exceptions raised by the client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PexafyError(Exception):
|
|
9
|
+
"""Base class for everything this package raises."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(PexafyError):
|
|
13
|
+
"""The API answered, but with an error.
|
|
14
|
+
|
|
15
|
+
``code`` is the machine readable identifier from the response envelope when
|
|
16
|
+
the server sent one; it is stable across versions and safe to branch on.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: Optional[int] = None,
|
|
24
|
+
code: Optional[str] = None,
|
|
25
|
+
request_id: Optional[str] = None,
|
|
26
|
+
payload: Optional[dict[str, Any]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.code = code
|
|
32
|
+
self.request_id = request_id
|
|
33
|
+
self.payload = payload or {}
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
bits = [self.message]
|
|
37
|
+
if self.status_code:
|
|
38
|
+
bits.append(f"(HTTP {self.status_code}")
|
|
39
|
+
if self.code:
|
|
40
|
+
bits[-1] += f", {self.code}"
|
|
41
|
+
bits[-1] += ")"
|
|
42
|
+
if self.request_id:
|
|
43
|
+
bits.append(f"request_id={self.request_id}")
|
|
44
|
+
return " ".join(bits)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthenticationError(APIError):
|
|
48
|
+
"""Missing, malformed or revoked API key."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PermissionError_(APIError):
|
|
52
|
+
"""The key is valid but lacks the scope for this call."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotFoundError(APIError):
|
|
56
|
+
"""No such photo, collection or photographer."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RateLimitError(APIError):
|
|
60
|
+
"""Too many requests, or the plan quota is exhausted.
|
|
61
|
+
|
|
62
|
+
``retry_after`` is the number of seconds the server asked us to wait, when
|
|
63
|
+
it said so.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
self.retry_after = retry_after
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationError(APIError):
|
|
72
|
+
"""The request was rejected before it reached the search engine."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServerError(APIError):
|
|
76
|
+
"""Something broke on our side. These are the ones worth retrying."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TimeoutError_(PexafyError):
|
|
80
|
+
"""The request took longer than the configured timeout."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConnectionError_(PexafyError):
|
|
84
|
+
"""The API could not be reached at all."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
STATUS_MAP = {
|
|
88
|
+
400: ValidationError,
|
|
89
|
+
401: AuthenticationError,
|
|
90
|
+
403: PermissionError_,
|
|
91
|
+
404: NotFoundError,
|
|
92
|
+
422: ValidationError,
|
|
93
|
+
429: RateLimitError,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def from_status(status: int) -> type[APIError]:
|
|
98
|
+
if status in STATUS_MAP:
|
|
99
|
+
return STATUS_MAP[status]
|
|
100
|
+
if status >= 500:
|
|
101
|
+
return ServerError
|
|
102
|
+
return APIError
|
pexafy/models.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Response objects.
|
|
2
|
+
|
|
3
|
+
Plain dataclasses rather than a validation library — the dependency footprint
|
|
4
|
+
stays at httpx alone, and unknown fields are kept in ``raw`` so a server side
|
|
5
|
+
addition never breaks a client that has not been updated yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_dt(value: Any) -> Optional[datetime]:
|
|
16
|
+
if not value or not isinstance(value, str):
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
20
|
+
except ValueError:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_date(value: Any) -> Optional[date]:
|
|
25
|
+
dt = _parse_dt(value)
|
|
26
|
+
return dt.date() if dt else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PhotoUrls:
|
|
31
|
+
"""The same image at five widths. Pick the smallest one that fits."""
|
|
32
|
+
|
|
33
|
+
thumb: str = ""
|
|
34
|
+
small: str = ""
|
|
35
|
+
regular: str = ""
|
|
36
|
+
large: str = ""
|
|
37
|
+
full: str = ""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, d: dict[str, Any]) -> PhotoUrls:
|
|
41
|
+
d = d or {}
|
|
42
|
+
return cls(
|
|
43
|
+
thumb=d.get("thumb", ""),
|
|
44
|
+
small=d.get("small", ""),
|
|
45
|
+
regular=d.get("regular", ""),
|
|
46
|
+
large=d.get("large", ""),
|
|
47
|
+
full=d.get("full", ""),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Attribution:
|
|
53
|
+
"""Credit line for the photographer, ready to drop into a page."""
|
|
54
|
+
|
|
55
|
+
html: str = ""
|
|
56
|
+
plain: str = ""
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, d: dict[str, Any]) -> Attribution:
|
|
60
|
+
d = d or {}
|
|
61
|
+
return cls(html=d.get("html", ""), plain=d.get("plain", ""))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Photo:
|
|
66
|
+
photo_id: str
|
|
67
|
+
image_url: str = ""
|
|
68
|
+
urls: PhotoUrls = field(default_factory=PhotoUrls)
|
|
69
|
+
width: Optional[int] = None
|
|
70
|
+
height: Optional[int] = None
|
|
71
|
+
blur_hash: Optional[str] = None
|
|
72
|
+
orientation: str = ""
|
|
73
|
+
color_name: str = ""
|
|
74
|
+
color_hex: str = ""
|
|
75
|
+
photographer_username: str = ""
|
|
76
|
+
photographer_full_name: Optional[str] = None
|
|
77
|
+
photographer_url: Optional[str] = None
|
|
78
|
+
source: str = ""
|
|
79
|
+
license_type: str = ""
|
|
80
|
+
source_image_url: Optional[str] = None
|
|
81
|
+
source_description: Optional[str] = None
|
|
82
|
+
description: Optional[str] = None
|
|
83
|
+
alt_description: Optional[str] = None
|
|
84
|
+
uploaded_on: Optional[date] = None
|
|
85
|
+
relevance_score: Optional[float] = None
|
|
86
|
+
attribution: Attribution = field(default_factory=Attribution)
|
|
87
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def aspect_ratio(self) -> Optional[float]:
|
|
91
|
+
if self.width and self.height:
|
|
92
|
+
return self.width / self.height
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def alt_text(self) -> str:
|
|
97
|
+
"""Best available text for an ``alt`` attribute."""
|
|
98
|
+
return self.alt_description or self.description or self.source_description or ""
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, d: dict[str, Any]) -> Photo:
|
|
102
|
+
return cls(
|
|
103
|
+
photo_id=d.get("photo_id", ""),
|
|
104
|
+
image_url=d.get("image_url", ""),
|
|
105
|
+
urls=PhotoUrls.from_dict(d.get("urls") or {}),
|
|
106
|
+
width=d.get("width"),
|
|
107
|
+
height=d.get("height"),
|
|
108
|
+
blur_hash=d.get("blur_hash"),
|
|
109
|
+
orientation=d.get("orientation", ""),
|
|
110
|
+
color_name=d.get("color_name", ""),
|
|
111
|
+
color_hex=d.get("color_hex", ""),
|
|
112
|
+
photographer_username=d.get("photographer_username", ""),
|
|
113
|
+
photographer_full_name=d.get("photographer_full_name"),
|
|
114
|
+
photographer_url=d.get("photographer_url"),
|
|
115
|
+
source=d.get("source", ""),
|
|
116
|
+
license_type=d.get("license_type", ""),
|
|
117
|
+
source_image_url=d.get("source_image_url"),
|
|
118
|
+
source_description=d.get("source_description"),
|
|
119
|
+
description=d.get("description"),
|
|
120
|
+
alt_description=d.get("alt_description"),
|
|
121
|
+
uploaded_on=_parse_date(d.get("uploaded_on")),
|
|
122
|
+
relevance_score=d.get("relevance_score"),
|
|
123
|
+
attribution=Attribution.from_dict(d.get("attribution") or {}),
|
|
124
|
+
raw=d,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Photographer:
|
|
130
|
+
username: str
|
|
131
|
+
full_name: Optional[str] = None
|
|
132
|
+
source: str = ""
|
|
133
|
+
url: Optional[str] = None
|
|
134
|
+
photos_count: int = 0
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, d: dict[str, Any]) -> Photographer:
|
|
138
|
+
return cls(
|
|
139
|
+
username=d.get("username", ""),
|
|
140
|
+
full_name=d.get("full_name"),
|
|
141
|
+
source=d.get("source", ""),
|
|
142
|
+
url=d.get("url"),
|
|
143
|
+
photos_count=d.get("photos_count", 0),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Collection:
|
|
149
|
+
id: int
|
|
150
|
+
name: str = ""
|
|
151
|
+
description: Optional[str] = None
|
|
152
|
+
is_public: bool = False
|
|
153
|
+
cover_photo_id: Optional[str] = None
|
|
154
|
+
photos_count: int = 0
|
|
155
|
+
created_at: Optional[datetime] = None
|
|
156
|
+
updated_at: Optional[datetime] = None
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_dict(cls, d: dict[str, Any]) -> Collection:
|
|
160
|
+
return cls(
|
|
161
|
+
id=d.get("id", 0),
|
|
162
|
+
name=d.get("name", ""),
|
|
163
|
+
description=d.get("description"),
|
|
164
|
+
is_public=bool(d.get("is_public", False)),
|
|
165
|
+
cover_photo_id=d.get("cover_photo_id"),
|
|
166
|
+
photos_count=d.get("photos_count", 0),
|
|
167
|
+
created_at=_parse_dt(d.get("created_at")),
|
|
168
|
+
updated_at=_parse_dt(d.get("updated_at")),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class CollectionItem:
|
|
174
|
+
id: int
|
|
175
|
+
photo_id: str
|
|
176
|
+
photo_thumbnail_url: Optional[str] = None
|
|
177
|
+
photo_source: Optional[str] = None
|
|
178
|
+
photo_photographer: Optional[str] = None
|
|
179
|
+
added_at: Optional[datetime] = None
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_dict(cls, d: dict[str, Any]) -> CollectionItem:
|
|
183
|
+
return cls(
|
|
184
|
+
id=d.get("id", 0),
|
|
185
|
+
photo_id=d.get("photo_id", ""),
|
|
186
|
+
photo_thumbnail_url=d.get("photo_thumbnail_url"),
|
|
187
|
+
photo_source=d.get("photo_source"),
|
|
188
|
+
photo_photographer=d.get("photo_photographer"),
|
|
189
|
+
added_at=_parse_dt(d.get("added_at")),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class Pagination:
|
|
195
|
+
next_cursor: Optional[str] = None
|
|
196
|
+
per_page: int = 0
|
|
197
|
+
has_more: bool = False
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_dict(cls, d: dict[str, Any]) -> Pagination:
|
|
201
|
+
d = d or {}
|
|
202
|
+
return cls(
|
|
203
|
+
next_cursor=d.get("next_cursor"),
|
|
204
|
+
per_page=d.get("per_page", 0),
|
|
205
|
+
has_more=bool(d.get("has_more", False)),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class SearchResult:
|
|
211
|
+
"""One page of results.
|
|
212
|
+
|
|
213
|
+
Iterating it yields photos, so ``for photo in client.search(...)`` reads the
|
|
214
|
+
way you would expect. Use :meth:`Client.iter_search` to walk every page.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
photos: list[Photo]
|
|
218
|
+
pagination: Pagination
|
|
219
|
+
request_id: str = ""
|
|
220
|
+
took_ms: Optional[float] = None
|
|
221
|
+
|
|
222
|
+
def __iter__(self):
|
|
223
|
+
return iter(self.photos)
|
|
224
|
+
|
|
225
|
+
def __len__(self) -> int:
|
|
226
|
+
return len(self.photos)
|
|
227
|
+
|
|
228
|
+
def __getitem__(self, index: int) -> Photo:
|
|
229
|
+
return self.photos[index]
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def next_cursor(self) -> Optional[str]:
|
|
233
|
+
return self.pagination.next_cursor
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def has_more(self) -> bool:
|
|
237
|
+
return self.pagination.has_more
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pexafy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Pexafy stock photo search API
|
|
5
|
+
Project-URL: Homepage, https://pexafy.com
|
|
6
|
+
Project-URL: Documentation, https://docs.pexafy.com
|
|
7
|
+
Project-URL: Source, https://github.com/Pexafy/pexafy-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Pexafy/pexafy-python/issues
|
|
9
|
+
Author-email: Marouane Tijani <marouane@pexafy.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api client,image search,semantic search,stock photos
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# pexafy-python
|
|
32
|
+
|
|
33
|
+
Python client for the [Pexafy](https://pexafy.com) image search API. Search a
|
|
34
|
+
catalogue of free stock photos by describing what you want, or by handing it an
|
|
35
|
+
image to match.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install pexafy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pexafy import Client
|
|
45
|
+
|
|
46
|
+
client = Client("your-api-key")
|
|
47
|
+
|
|
48
|
+
for photo in client.search("a quiet street in the rain", per_page=5):
|
|
49
|
+
print(photo.urls.regular, "-", photo.alt_text)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The key comes from your [dashboard](https://pexafy.com/dashboard/); the free
|
|
53
|
+
tier does not ask for a card. If you would rather not put it in the code, the
|
|
54
|
+
client picks up `PEXAFY_API_KEY` from the environment.
|
|
55
|
+
|
|
56
|
+
## Writing queries
|
|
57
|
+
|
|
58
|
+
Search runs on meaning, not keywords, so full sentences work better than a pile
|
|
59
|
+
of nouns. `two people hiking on a ridge at dawn` finds what you would expect;
|
|
60
|
+
`hiking dawn people` gives you a worse ranking, because you have thrown away
|
|
61
|
+
the relationships between the words.
|
|
62
|
+
|
|
63
|
+
Filters narrow the result set after the semantic match:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
result = client.search(
|
|
67
|
+
"an empty office at night",
|
|
68
|
+
orientation="landscape",
|
|
69
|
+
color_name="blue",
|
|
70
|
+
source=["Pexels", "Unsplash"],
|
|
71
|
+
per_page=20,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print(len(result), "photos in", result.took_ms, "ms")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
List filters accept either a list or a comma separated string.
|
|
78
|
+
|
|
79
|
+
## Paging
|
|
80
|
+
|
|
81
|
+
A single call returns one page. `iter_search` follows the cursor for you and
|
|
82
|
+
yields photos until the results run out or you have seen enough:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
for photo in client.iter_search("vintage typewriter", max_results=200):
|
|
86
|
+
download(photo.urls.large)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Search by image
|
|
90
|
+
|
|
91
|
+
Pass a path, raw bytes, or an open file:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
similar = client.search_by_image("moodboard/reference.jpg", per_page=12)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If you already have a photo id, `client.similar(photo_id)` is cheaper — the
|
|
98
|
+
image does not have to be uploaded and encoded again.
|
|
99
|
+
|
|
100
|
+
## Async
|
|
101
|
+
|
|
102
|
+
The same surface, awaitable:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import asyncio
|
|
106
|
+
from pexafy import AsyncClient
|
|
107
|
+
|
|
108
|
+
async def main():
|
|
109
|
+
async with AsyncClient() as client:
|
|
110
|
+
pages = await asyncio.gather(
|
|
111
|
+
client.search("desert road"),
|
|
112
|
+
client.search("snow covered pines"),
|
|
113
|
+
)
|
|
114
|
+
for page in pages:
|
|
115
|
+
print(len(page))
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
Everything raised inherits from `PexafyError`. The ones worth catching
|
|
123
|
+
separately:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from pexafy import errors
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
client.search("...")
|
|
130
|
+
except errors.RateLimitError as exc:
|
|
131
|
+
time.sleep(exc.retry_after or 60)
|
|
132
|
+
except errors.AuthenticationError:
|
|
133
|
+
... # key is missing, malformed or revoked
|
|
134
|
+
except errors.APIError as exc:
|
|
135
|
+
print(exc.status_code, exc.code, exc.request_id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`request_id` is worth logging. It is the fastest way to get an answer if you
|
|
139
|
+
need to ask about a specific call.
|
|
140
|
+
|
|
141
|
+
Timeouts and 5xx responses are retried twice with backoff, and `Retry-After` is
|
|
142
|
+
honoured when the server sends it. Set `max_retries=0` if you would rather
|
|
143
|
+
handle that yourself.
|
|
144
|
+
|
|
145
|
+
## Command line
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
$ pexafy search "morning fog over pine trees" -n 3
|
|
149
|
+
0.847 019e0eb8-b028-73cb-9296-dfa70f557bc9 4000x2667 green Pexels https://...
|
|
150
|
+
0.812 019e4c9b-3022-7660-b43d-e730b8435f24 6000x4000 green Unsplash https://...
|
|
151
|
+
0.798 019e4f39-66d4-7ef2-bc9b-eb5340fd243e 3648x5472 grey Pexels https://...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`pexafy photo <id>`, `pexafy similar <id>` and `pexafy usage` are also there.
|
|
155
|
+
Add `--json` to any of them for the raw response.
|
|
156
|
+
|
|
157
|
+
## Attribution
|
|
158
|
+
|
|
159
|
+
Photos come from several providers with different licence terms. Every photo
|
|
160
|
+
carries an `attribution` object with a ready made credit line:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
photo.attribution.plain # Photo by J. Doe
|
|
164
|
+
photo.attribution.html # <a href="...">J. Doe</a>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Check `photo.license_type` if your use depends on it.
|
|
168
|
+
|
|
169
|
+
## Reference
|
|
170
|
+
|
|
171
|
+
| Method | What it does |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `search(q, **filters)` | one page of results |
|
|
174
|
+
| `iter_search(q, max_results=None, **filters)` | every page, cursor handled |
|
|
175
|
+
| `search_by_image(image, **filters)` | match an image you supply |
|
|
176
|
+
| `get_photo(photo_id)` | one photo by id |
|
|
177
|
+
| `similar(photo_id, **filters)` | photos close to an existing one |
|
|
178
|
+
| `colors()` `sources()` `orientations()` `licenses()` | filter values you can use |
|
|
179
|
+
| `suggest_photographers(q)` `photographer(username)` | photographer lookup |
|
|
180
|
+
| `collections()` `create_collection(name)` `add_to_collection(id, photo_id)` | saved sets |
|
|
181
|
+
| `usage()` `usage_daily()` `usage_monthly()` `usage_by_key()` | where you are against your quota |
|
|
182
|
+
|
|
183
|
+
Full API documentation is at [docs.pexafy.com](https://docs.pexafy.com).
|
|
184
|
+
|
|
185
|
+
## Requirements
|
|
186
|
+
|
|
187
|
+
Python 3.9 or newer. The only dependency is `httpx`.
|
|
188
|
+
|
|
189
|
+
## Licence
|
|
190
|
+
|
|
191
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pexafy/__init__.py,sha256=CuOAdSTbI7M5hHUm3PIAOtt3UxzsQ83dl-ZVHKrTIME,1215
|
|
2
|
+
pexafy/cli.py,sha256=dYbtULAKddorxzDO_KhyHkf3298zoAX3og3AlQGEnGw,2969
|
|
3
|
+
pexafy/client.py,sha256=9qw30J-k-6cJRi4YJFptnh0HxnKmH_cqdoiLfO7MjaE,16854
|
|
4
|
+
pexafy/errors.py,sha256=_jAPl0kinUemEA2l6wvU5w8HpThSjXz27mIX5KtGi7g,2653
|
|
5
|
+
pexafy/models.py,sha256=e7UI469qcsHjO84hq73DaWrh0UVJxqA4UqETaBoaCqI,7008
|
|
6
|
+
pexafy-0.1.0.dist-info/METADATA,sha256=G0iX9c77pUcQe9N4AzFT2fAp0KeTo_ddoF1MJdKb2lM,5835
|
|
7
|
+
pexafy-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
pexafy-0.1.0.dist-info/entry_points.txt,sha256=Q5qXAXgnc6P2uzdUcC7qmfgX6oKWALSlVzMH0RZ_gJk,43
|
|
9
|
+
pexafy-0.1.0.dist-info/licenses/LICENSE,sha256=witmea33bfrUIQwxv9UzvIkCJKVWehbqCb9mKF1NNeI,1072
|
|
10
|
+
pexafy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marouane Tijani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|