pySplash.py 1.0.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.
pySplash/base.py ADDED
@@ -0,0 +1,142 @@
1
+ """Shared utilities for sync and async API clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from typing import Any
8
+
9
+ from .errors import PySplashError
10
+
11
+ logger = logging.getLogger("pySplash.py")
12
+
13
+
14
+ class PySplashBase:
15
+ """Shared validation, error handling, and URL construction for both sync and async clients."""
16
+
17
+ @staticmethod
18
+ def _compute_hash(value: str) -> str:
19
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
20
+
21
+ @staticmethod
22
+ def _validate_required(value: Any, field_name: str) -> None:
23
+ if value is None or value == "":
24
+ if field_name == "id":
25
+ message = "Parameter : id is required!"
26
+ elif field_name == "query":
27
+ message = "Parameter : query is missing!"
28
+ else:
29
+ message = f"Parameter : {field_name} is required and cannot be empty!"
30
+ raise PySplashError(message)
31
+
32
+ @staticmethod
33
+ def _validate_supported_value(value: str | None, allowed_values: list, field_name: str) -> None:
34
+ if value is not None and value not in allowed_values:
35
+ raise PySplashError(f"Parameter : {field_name} has an unsupported value!")
36
+
37
+ @staticmethod
38
+ def _validate_int(value: Any, field_name: str, minimum: int = 1, maximum: int | None = None) -> None:
39
+ if value is not None:
40
+ if not isinstance(value, int) or isinstance(value, bool):
41
+ raise PySplashError(f"Parameter : {field_name} must be an integer!")
42
+ if value < minimum:
43
+ raise PySplashError(f"Parameter : {field_name} must be >= {minimum}!")
44
+ if maximum is not None and value > maximum:
45
+ raise PySplashError(f"Parameter : {field_name} must be <= {maximum}!")
46
+
47
+ @staticmethod
48
+ def _validate_bool(value: Any, field_name: str) -> None:
49
+ if value is not None and not isinstance(value, bool):
50
+ raise PySplashError(f"Parameter : {field_name} must be a boolean or optional!")
51
+
52
+ @staticmethod
53
+ def _build_query_parameters(params: dict[str, Any]) -> dict[str, Any]:
54
+ return {k: v for k, v in params.items() if v is not None and v != ""}
55
+
56
+ @staticmethod
57
+ def _build_url(base: str, path: str, **replacements: str) -> str:
58
+ url = base + path
59
+ for key, value in replacements.items():
60
+ url = url.replace(f":{key}", value)
61
+ return url
62
+
63
+ @staticmethod
64
+ def _handle_response_status(data: Any, status: int, status_text: str) -> Any:
65
+ if status == 204:
66
+ logger.debug("204 No Content - resource deleted")
67
+ return {"status": status, "statusText": status_text, "message": "Content Deleted"}
68
+ if status >= 400:
69
+ logger.warning("%d %s", status, status_text)
70
+ raise PySplashError(
71
+ f"HTTP {status} {status_text}",
72
+ status_code=status,
73
+ status_text=status_text,
74
+ )
75
+ return data
76
+
77
+ @staticmethod
78
+ def _create_pysplash_error(error: Exception) -> PySplashError:
79
+ if isinstance(error, PySplashError):
80
+ return error
81
+ status_code = getattr(getattr(error, "response", None), "status_code", None)
82
+ status_text = getattr(
83
+ getattr(error, "response", None),
84
+ "reason",
85
+ getattr(getattr(error, "response", None), "reason_phrase", None),
86
+ )
87
+ return PySplashError(
88
+ str(error),
89
+ status_code=status_code,
90
+ status_text=status_text,
91
+ cause=error,
92
+ )
93
+
94
+ @staticmethod
95
+ def _default_headers(bearer_token: str | None = None, access_key: str | None = None) -> dict[str, str]:
96
+ headers: dict[str, str] = {
97
+ "Content-type": "application/json",
98
+ "X-Requested-With": "PySplash.py",
99
+ }
100
+ if bearer_token:
101
+ headers["Authorization"] = f"Bearer {bearer_token}"
102
+ headers["X-PySplash-Header"] = PySplashBase._compute_hash(bearer_token)
103
+ elif access_key:
104
+ headers["Authorization"] = f"Client-ID {access_key}"
105
+ headers["X-PySplash-Header"] = PySplashBase._compute_hash(access_key)
106
+ return headers
107
+
108
+ @staticmethod
109
+ def _extract_location_params(location: dict[str, Any] | None) -> dict[str, Any]:
110
+ if not location:
111
+ return {}
112
+ result: dict[str, Any] = {}
113
+ mapping = {
114
+ "latitude": "location[latitude]",
115
+ "longitude": "location[longitude]",
116
+ "name": "location[name]",
117
+ "city": "location[city]",
118
+ "country": "location[country]",
119
+ "confidential": "location[confidential]",
120
+ }
121
+ for key, param_name in mapping.items():
122
+ if key in location and location[key] is not None:
123
+ result[param_name] = location[key]
124
+ return result
125
+
126
+ @staticmethod
127
+ def _extract_exif_params(exif: dict[str, Any] | None) -> dict[str, Any]:
128
+ if not exif:
129
+ return {}
130
+ result: dict[str, Any] = {}
131
+ mapping = {
132
+ "make": "exif[make]",
133
+ "model": "exif[model]",
134
+ "exposure_time": "exif[exposure_time]",
135
+ "aperture_value": "exif[aperture_value]",
136
+ "focal_length": "exif[focal_length]",
137
+ "iso_speed_ratings": "exif[iso_speed_ratings]",
138
+ }
139
+ for key, param_name in mapping.items():
140
+ if key in exif and exif[key] is not None:
141
+ result[param_name] = exif[key]
142
+ return result
pySplash/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Configuration for pySplash.py - URL templates and default HTTP settings."""
2
+
3
+ API_LOCATION = "https://api.unsplash.com/"
4
+ BEARER_TOKEN_URL = "https://unsplash.com/oauth/token"
5
+
6
+ URL_CONFIG = {
7
+ "API_LOCATION": API_LOCATION,
8
+ "BEARER_TOKEN_URL": BEARER_TOKEN_URL,
9
+ "USERS_PUBLIC_PROFILE": "users/",
10
+ "USERS_PORTFOLIO": "users/:username/portfolio",
11
+ "USERS_PHOTOS": "users/:username/photos",
12
+ "USERS_LIKED_PHOTOS": "users/:username/likes",
13
+ "USERS_COLLECTIONS": "users/:username/collections",
14
+ "USERS_STATISTICS": "users/:username/statistics",
15
+ "LIST_PHOTOS": "photos",
16
+ "LIST_CURATED_PHOTOS": "photos/curated",
17
+ "GET_A_PHOTO": "photos/:id",
18
+ "GET_A_RANDOM_PHOTO": "photos/random",
19
+ "GET_A_PHOTO_STATISTICS": "photos/:id/statistics",
20
+ "GET_A_PHOTO_DOWNLOAD_LINK": "photos/:id/download",
21
+ "UPDATE_A_PHOTO": "photos/:id",
22
+ "LIKE_A_PHOTO": "photos/:id/like",
23
+ "UNLIKE_A_PHOTO": "photos/:id/like",
24
+ "SEARCH_PHOTOS": "search/photos",
25
+ "SEARCH_COLLECTIONS": "search/collections",
26
+ "SEARCH_USERS": "search/users",
27
+ "CURRENT_USER_PROFILE": "me",
28
+ "UPDATE_CURRENT_USER_PROFILE": "me",
29
+ "STATS_TOTALS": "stats/total",
30
+ "STATS_MONTH": "stats/month",
31
+ "LIST_COLLECTIONS": "collections",
32
+ "LIST_FEATURED_COLLECTIONS": "collections/featured",
33
+ "LIST_CURATED_COLLECTIONS": "collections/curated",
34
+ "GET_COLLECTION": "collections/:id",
35
+ "GET_CURATED_COLLECTION": "collections/curated/:id",
36
+ "GET_COLLECTION_PHOTOS": "collections/:id/photos",
37
+ "GET_CURATED_COLLECTION_PHOTOS": "collections/curated/:id/photos",
38
+ "LIST_RELATED_COLLECTION": "collections/:id/related",
39
+ "CREATE_NEW_COLLECTION": "collections",
40
+ "UPDATE_EXISTING_COLLECTION": "collections/:id",
41
+ "DELETE_COLLECTION": "collections/:id",
42
+ "ADD_PHOTO_TO_COLLECTION": "collections/:collection_id/add",
43
+ "REMOVE_PHOTO_FROM_COLLECTION": "collections/:collection_id/remove",
44
+ }
45
+
46
+ DEFAULT_TIMEOUT = 10
47
+ DEFAULT_RETRIES = 2
48
+ DEFAULT_RETRY_DELAY = 0.1
49
+ DEFAULT_HEADERS = {
50
+ "Content-type": "application/json",
51
+ "X-Requested-With": "PySplash.py",
52
+ }
53
+
54
+ AVAILABLE_ORDERS = ["latest", "oldest", "popular"]
55
+ AVAILABLE_ORIENTATIONS = ["landscape", "portrait", "squarish"]
pySplash/errors.py ADDED
@@ -0,0 +1,30 @@
1
+ """Custom exception class for pySplash.py errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class PySplashError(Exception):
9
+ """Exception raised by pySplash.py for API and validation errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ status_code: int | None = None,
15
+ status_text: str | None = None,
16
+ cause: Any | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.name = "PySplashError"
20
+ self.status_code = status_code
21
+ self.statusText = status_text
22
+ self.cause = cause
23
+
24
+ def __repr__(self) -> str:
25
+ parts = [f"PySplashError({self.args[0]!r}"]
26
+ if self.status_code is not None:
27
+ parts.append(f"status_code={self.status_code}")
28
+ if self.statusText is not None:
29
+ parts.append(f"status_text={self.statusText!r}")
30
+ return ", ".join(parts) + ")"
@@ -0,0 +1,97 @@
1
+ """Synchronous HTTP client with retry support using requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from .config import DEFAULT_HEADERS, DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, DEFAULT_TIMEOUT
12
+ from .errors import PySplashError
13
+
14
+ logger = logging.getLogger("pySplash.py")
15
+
16
+
17
+ class HttpResponse:
18
+ """Wrapper around requests.Response to provide a uniform interface."""
19
+
20
+ def __init__(self, response: requests.Response) -> None:
21
+ self.status: int = response.status_code
22
+ self.statusText: str = response.reason or ""
23
+ try:
24
+ self.data: Any = response.json() if response.content else None
25
+ except ValueError:
26
+ self.data = response.text or None
27
+
28
+ def raise_for_status(self) -> None:
29
+ if self.status >= 400:
30
+ raise PySplashError(
31
+ f"HTTP {self.status} {self.statusText}",
32
+ status_code=self.status,
33
+ status_text=self.statusText,
34
+ )
35
+
36
+
37
+ class HttpClient:
38
+ """Sync HTTP client with session reuse, retry logic, and error handling."""
39
+
40
+ def __init__(
41
+ self,
42
+ headers: dict[str, str] | None = None,
43
+ timeout: int = DEFAULT_TIMEOUT,
44
+ retries: int = DEFAULT_RETRIES,
45
+ retry_delay: float = DEFAULT_RETRY_DELAY,
46
+ ) -> None:
47
+ self._session = requests.Session()
48
+ self._session.headers.update(headers or DEFAULT_HEADERS)
49
+ self.timeout = timeout
50
+ self.retries = retries
51
+ self.retry_delay = retry_delay
52
+
53
+ def close(self) -> None:
54
+ self._session.close()
55
+
56
+ def __enter__(self) -> HttpClient:
57
+ return self
58
+
59
+ def __exit__(self, *args: Any) -> None:
60
+ self.close()
61
+
62
+ def make_request(
63
+ self,
64
+ url: str,
65
+ method: str,
66
+ query_parameters: dict[str, Any] | None = None,
67
+ body: Any | None = None,
68
+ ) -> HttpResponse:
69
+ if not url:
70
+ raise ValueError("URL required")
71
+
72
+ last_error: Exception | None = None
73
+
74
+ for attempt in range(self.retries + 1):
75
+ try:
76
+ logger.debug("%s %s (attempt %d/%d)", method, url, attempt + 1, self.retries + 1)
77
+ response = self._session.request(
78
+ method=method or "get",
79
+ url=url,
80
+ params=query_parameters or {},
81
+ json=body,
82
+ timeout=self.timeout,
83
+ allow_redirects=True,
84
+ )
85
+ result = HttpResponse(response)
86
+ if result.status >= 400:
87
+ result.raise_for_status()
88
+ return result
89
+ except PySplashError:
90
+ raise
91
+ except Exception as exc:
92
+ last_error = exc
93
+ logger.warning("Request failed (attempt %d/%d): %s", attempt + 1, self.retries + 1, exc)
94
+ if attempt < self.retries and self.retry_delay > 0:
95
+ time.sleep(self.retry_delay)
96
+
97
+ raise last_error # type: ignore[misc]
@@ -0,0 +1,107 @@
1
+ """Asynchronous HTTP client with retry support using httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .config import DEFAULT_HEADERS, DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, DEFAULT_TIMEOUT
12
+ from .errors import PySplashError
13
+
14
+ logger = logging.getLogger("pySplash.py")
15
+
16
+
17
+ class AsyncHttpResponse:
18
+ """Wrapper around httpx.Response to provide a uniform interface."""
19
+
20
+ def __init__(self, response: httpx.Response) -> None:
21
+ self.status: int = response.status_code
22
+ self.statusText: str = response.reason_phrase or ""
23
+ try:
24
+ self.data: Any = response.json() if response.content else None
25
+ except ValueError:
26
+ self.data = response.text or None
27
+
28
+ def raise_for_status(self) -> None:
29
+ if self.status >= 400:
30
+ raise PySplashError(
31
+ f"HTTP {self.status} {self.statusText}",
32
+ status_code=self.status,
33
+ status_text=self.statusText,
34
+ )
35
+
36
+
37
+ class AsyncHttpClient:
38
+ """Async HTTP client with connection pooling, retry logic, and error handling."""
39
+
40
+ def __init__(
41
+ self,
42
+ headers: dict[str, str] | None = None,
43
+ timeout: int = DEFAULT_TIMEOUT,
44
+ retries: int = DEFAULT_RETRIES,
45
+ retry_delay: float = DEFAULT_RETRY_DELAY,
46
+ ) -> None:
47
+ self._headers = headers or DEFAULT_HEADERS
48
+ self.timeout = timeout
49
+ self.retries = retries
50
+ self.retry_delay = retry_delay
51
+ self._client: httpx.AsyncClient | None = None
52
+
53
+ def _get_client(self) -> httpx.AsyncClient:
54
+ if self._client is None or self._client.is_closed:
55
+ self._client = httpx.AsyncClient(
56
+ headers=self._headers,
57
+ timeout=self.timeout,
58
+ max_redirects=5,
59
+ )
60
+ return self._client
61
+
62
+ async def close(self) -> None:
63
+ if self._client is not None and not self._client.is_closed:
64
+ await self._client.aclose()
65
+ self._client = None
66
+
67
+ async def __aenter__(self) -> AsyncHttpClient:
68
+ return self
69
+
70
+ async def __aexit__(self, *args: Any) -> None:
71
+ await self.close()
72
+
73
+ async def make_request(
74
+ self,
75
+ url: str,
76
+ method: str,
77
+ query_parameters: dict[str, Any] | None = None,
78
+ body: Any | None = None,
79
+ ) -> AsyncHttpResponse:
80
+ if not url:
81
+ raise ValueError("URL required")
82
+
83
+ last_error: Exception | None = None
84
+
85
+ for attempt in range(self.retries + 1):
86
+ try:
87
+ logger.debug("%s %s (attempt %d/%d)", method, url, attempt + 1, self.retries + 1)
88
+ client = self._get_client()
89
+ response = await client.request(
90
+ method=method or "get",
91
+ url=url,
92
+ params=query_parameters or {},
93
+ json=body,
94
+ )
95
+ result = AsyncHttpResponse(response)
96
+ if result.status >= 400:
97
+ result.raise_for_status()
98
+ return result
99
+ except PySplashError:
100
+ raise
101
+ except Exception as exc:
102
+ last_error = exc
103
+ logger.warning("Request failed (attempt %d/%d): %s", attempt + 1, self.retries + 1, exc)
104
+ if attempt < self.retries and self.retry_delay > 0:
105
+ await asyncio.sleep(self.retry_delay)
106
+
107
+ raise last_error # type: ignore[misc]
pySplash/models.py ADDED
@@ -0,0 +1,173 @@
1
+ """Typed response models for the Unsplash API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypedDict
6
+
7
+
8
+ class UserLinks(TypedDict, total=False):
9
+ self: str
10
+ html: str
11
+ photos: str
12
+ likes: str
13
+ portfolio: str
14
+
15
+
16
+ class UserUrls(TypedDict, total=False):
17
+ raw: str
18
+ full: str
19
+ regular: str
20
+ small: str
21
+ medium: str
22
+ large: str
23
+ profile_image: str
24
+
25
+
26
+ class UserProfileImages(TypedDict, total=False):
27
+ small: str
28
+ medium: str
29
+ large: str
30
+
31
+
32
+ class User(TypedDict, total=False):
33
+ id: str
34
+ username: str
35
+ name: str
36
+ first_name: str
37
+ last_name: str
38
+ instagram_username: str
39
+ total_likes: int
40
+ total_photos: int
41
+ total_collections: int
42
+ followers_count: int
43
+ following_count: int
44
+ downloads: int
45
+ bio: str | None
46
+ location: str | None
47
+ total_promoted_photos: int
48
+ followers: int
49
+ following: int
50
+ profile_image: UserProfileImages
51
+ social: dict[str, Any]
52
+ links: UserLinks
53
+ tags: list[dict[str, Any]]
54
+ photos: list[dict[str, Any]]
55
+ portfolio_url: str | None
56
+ updated_at: str
57
+ badge: dict[str, Any]
58
+ user_links: UserLinks
59
+
60
+
61
+ class UserPortfolio(TypedDict, total=False):
62
+ id: str
63
+ title: str
64
+ url: str
65
+ published_at: str
66
+ updated_at: str
67
+ links: dict[str, str]
68
+
69
+
70
+ class UserStatistics(TypedDict, total=False):
71
+ id: str
72
+ username: str
73
+ downloads: dict[str, Any]
74
+ views: dict[str, Any]
75
+ likes: dict[str, Any]
76
+ historical: dict[str, Any]
77
+
78
+
79
+ class Urls(TypedDict, total=False):
80
+ raw: str
81
+ full: str
82
+ regular: str
83
+ small: str
84
+ thumb: str
85
+ small_s3: str
86
+
87
+
88
+ class PhotoLinks(TypedDict, total=False):
89
+ self: str
90
+ html: str
91
+ download: str
92
+ download_location: str
93
+
94
+
95
+ class Photo(TypedDict, total=False):
96
+ id: str
97
+ slug: str
98
+ created_at: str
99
+ updated_at: str
100
+ promoted_at: str | None
101
+ width: int
102
+ height: int
103
+ color: str
104
+ blur_hash: str
105
+ description: str | None
106
+ alt_description: str | None
107
+ breadcrumbs: list[dict[str, Any]]
108
+ urls: Urls
109
+ links: PhotoLinks
110
+ likes: int
111
+ liked_by_user: bool
112
+ current_user_collections: list[dict[str, Any]]
113
+ user: User
114
+ exif: dict[str, Any]
115
+ location: dict[str, Any]
116
+ views: int
117
+ downloads: int
118
+ related_collections: dict[str, Any]
119
+ tags: list[dict[str, Any]]
120
+ sponsorship: dict[str, Any] | None
121
+ topic_submissions: dict[str, Any]
122
+ user_links: UserLinks
123
+
124
+
125
+ class PhotoStatistics(TypedDict, total=False):
126
+ id: str
127
+ downloads: dict[str, Any]
128
+ views: dict[str, Any]
129
+ likes: dict[str, Any]
130
+ historical: dict[str, Any]
131
+
132
+
133
+ class CollectionLinks(TypedDict, total=False):
134
+ self: str
135
+ html: str
136
+ photos: str
137
+ related: str
138
+
139
+
140
+ class Collection(TypedDict, total=False):
141
+ id: str
142
+ title: str
143
+ description: str | None
144
+ published_at: str
145
+ updated_at: str
146
+ curated: bool
147
+ featured: bool
148
+ total_photos: int
149
+ private: bool
150
+ share_key: str
151
+ tags: list[dict[str, Any]]
152
+ links: CollectionLinks
153
+ user: User
154
+ cover_photo: Photo
155
+
156
+
157
+ class SearchResult(TypedDict, total=False):
158
+ total: int
159
+ total_pages: int
160
+ results: list[dict[str, Any]]
161
+
162
+
163
+ class BearerTokenResponse(TypedDict, total=False):
164
+ access_token: str
165
+ token_type: str
166
+ scope: str
167
+ created_at: int
168
+
169
+
170
+ class DeleteResponse(TypedDict, total=False):
171
+ status: int
172
+ statusText: str
173
+ message: str
pySplash/py.typed ADDED
File without changes