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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pexafy = pexafy.cli:main
@@ -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.