listingsapi 0.5.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.
@@ -0,0 +1,46 @@
1
+ """listingsAPI Python SDK — listings, reviews, posts, and analytics for local marketing.
2
+
3
+ Example:
4
+ import listingsapi
5
+
6
+ client = listingsapi.ListingsAPI() # reads LISTINGSAPI_KEY from env
7
+
8
+ for location in client.locations.list(first=10):
9
+ print(location.name, location.city)
10
+ """
11
+
12
+ from listingsapi._client import ListingsAPI
13
+ from listingsapi._types import APIObject, SyncPage
14
+ from listingsapi.exceptions import (
15
+ APIConnectionError,
16
+ APIError,
17
+ AuthenticationError,
18
+ InternalServerError,
19
+ ListingsAPIError,
20
+ NotFoundError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ ValidationError,
24
+ )
25
+
26
+ __version__ = "0.5.0"
27
+
28
+ __all__ = [
29
+ # Client
30
+ "ListingsAPI",
31
+ # Types
32
+ "APIObject",
33
+ "SyncPage",
34
+ # Exceptions
35
+ "ListingsAPIError",
36
+ "APIError",
37
+ "AuthenticationError",
38
+ "PermissionDeniedError",
39
+ "NotFoundError",
40
+ "ValidationError",
41
+ "RateLimitError",
42
+ "InternalServerError",
43
+ "APIConnectionError",
44
+ # Version
45
+ "__version__",
46
+ ]
listingsapi/_client.py ADDED
@@ -0,0 +1,269 @@
1
+ """Main listingsAPI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ import requests
10
+ from requests.adapters import HTTPAdapter
11
+ from urllib3.util.retry import Retry
12
+
13
+ from listingsapi._types import APIObject
14
+ from listingsapi._utils import encode_location_id
15
+ from listingsapi.exceptions import (
16
+ APIConnectionError,
17
+ APIError,
18
+ AuthenticationError,
19
+ InternalServerError,
20
+ NotFoundError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ ValidationError,
24
+ )
25
+
26
+ logger = logging.getLogger("listingsapi")
27
+
28
+ DEFAULT_BASE_URL = "https://listingsapi.com"
29
+ DEFAULT_TIMEOUT = 240.0
30
+ DEFAULT_MAX_RETRIES = 2
31
+
32
+
33
+ class ListingsAPI:
34
+ """Client for the listingsAPI platform.
35
+
36
+ Example:
37
+ import listingsapi
38
+
39
+ # Reads LISTINGSAPI_KEY from environment
40
+ client = listingsapi.ListingsAPI()
41
+
42
+ # Or pass explicitly
43
+ client = listingsapi.ListingsAPI(api_key="your-key")
44
+
45
+ # With custom config
46
+ client = listingsapi.ListingsAPI(api_key="...", timeout=60.0, max_retries=3)
47
+
48
+ # Use resources
49
+ page = client.locations.list(first=10)
50
+ for location in page:
51
+ print(location.name, location.city)
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ api_key: str | None = None,
57
+ *,
58
+ base_url: str | None = None,
59
+ timeout: float = DEFAULT_TIMEOUT,
60
+ max_retries: int = DEFAULT_MAX_RETRIES,
61
+ ):
62
+ self.api_key = api_key or os.environ.get("LISTINGSAPI_KEY")
63
+ if not self.api_key:
64
+ raise AuthenticationError(
65
+ "No API key provided. Set the LISTINGSAPI_KEY environment variable "
66
+ "or pass api_key= to ListingsAPI().",
67
+ status_code=401,
68
+ )
69
+
70
+ self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
71
+ self._timeout = timeout
72
+
73
+ self._session = requests.Session()
74
+ self._session.headers.update(
75
+ {
76
+ "Authorization": f"API {self.api_key}",
77
+ "Content-Type": "application/json",
78
+ }
79
+ )
80
+
81
+ retry = Retry(
82
+ total=max_retries,
83
+ backoff_factor=0.5,
84
+ status_forcelist=[429, 500, 502, 503, 504],
85
+ allowed_methods=["GET", "POST"],
86
+ respect_retry_after_header=True,
87
+ )
88
+ adapter = HTTPAdapter(max_retries=retry)
89
+ self._session.mount("https://", adapter)
90
+ self._session.mount("http://", adapter)
91
+
92
+ # Resources — lazy imports to avoid circular deps
93
+ from listingsapi.resources import (
94
+ Analytics,
95
+ ConnectedAccounts,
96
+ Listings,
97
+ Locations,
98
+ Photos,
99
+ Posts,
100
+ Reviews,
101
+ Workflows,
102
+ )
103
+
104
+ self.locations = Locations(self)
105
+ self.reviews = Reviews(self)
106
+ self.posts = Posts(self)
107
+ self.listings = Listings(self)
108
+ self.analytics = Analytics(self)
109
+ self.connected_accounts = ConnectedAccounts(self)
110
+ self.photos = Photos(self)
111
+ self.workflows = Workflows(self)
112
+
113
+ # --- HTTP methods (used by resources) ---
114
+
115
+ def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
116
+ url = f"{self._base_url}/api/v4/{path}"
117
+ logger.debug("GET %s params=%s", url, params)
118
+ try:
119
+ response = self._session.get(url, params=params or {}, timeout=self._timeout)
120
+ except requests.ConnectionError as e:
121
+ raise APIConnectionError(f"Connection error: {e}") from e
122
+ except requests.Timeout as e:
123
+ raise APIConnectionError(f"Request timed out: {e}") from e
124
+ return self._handle_response(response)
125
+
126
+ def _post(self, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
127
+ url = f"{self._base_url}/api/v4/{path}"
128
+ logger.debug("POST %s", url)
129
+ try:
130
+ response = self._session.post(url, json=json_body, timeout=self._timeout)
131
+ except requests.ConnectionError as e:
132
+ raise APIConnectionError(f"Connection error: {e}") from e
133
+ except requests.Timeout as e:
134
+ raise APIConnectionError(f"Request timed out: {e}") from e
135
+ data = self._handle_response(response)
136
+ self._raise_for_mutation_errors(data, response)
137
+ return data
138
+
139
+ def _delete(self, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
140
+ url = f"{self._base_url}/api/v4/{path}"
141
+ logger.debug("DELETE %s", url)
142
+ try:
143
+ response = self._session.delete(url, json=json_body, timeout=self._timeout)
144
+ except requests.ConnectionError as e:
145
+ raise APIConnectionError(f"Connection error: {e}") from e
146
+ except requests.Timeout as e:
147
+ raise APIConnectionError(f"Request timed out: {e}") from e
148
+ data = self._handle_response(response)
149
+ self._raise_for_mutation_errors(data, response)
150
+ return data
151
+
152
+ def _location_get(
153
+ self, location_id: str | int, path_suffix: str, params: dict | None = None
154
+ ) -> dict[str, Any]:
155
+ encoded_id = encode_location_id(location_id)
156
+ return self._get(f"locations/{encoded_id}/{path_suffix}", params)
157
+
158
+ def _handle_response(self, response: requests.Response) -> dict[str, Any]:
159
+ if response.ok:
160
+ data = response.json()
161
+ if isinstance(data, dict):
162
+ errors = _parse_error_entries(data.get("errors"))
163
+ if errors:
164
+ raise _exception_for_errors(errors, response)
165
+ return data
166
+
167
+ status = response.status_code
168
+ body = response.text
169
+ msg = f"API request failed: {status}"
170
+
171
+ if status == 401:
172
+ raise AuthenticationError(msg, status_code=status, response_body=body)
173
+ elif status == 403:
174
+ raise PermissionDeniedError(msg, status_code=status, response_body=body)
175
+ elif status == 404:
176
+ raise NotFoundError(msg, status_code=status, response_body=body)
177
+ elif status == 429:
178
+ retry_after = response.headers.get("Retry-After")
179
+ raise RateLimitError(msg, status_code=status, response_body=body, retry_after=retry_after)
180
+ elif status in (400, 422):
181
+ raise ValidationError(msg, status_code=status, response_body=body)
182
+ elif status >= 500:
183
+ raise InternalServerError(msg, status_code=status, response_body=body)
184
+ else:
185
+ raise APIError(msg, status_code=status, response_body=body)
186
+
187
+ def _raise_for_mutation_errors(self, data: dict[str, Any], response: requests.Response) -> None:
188
+ if not isinstance(data, dict):
189
+ return
190
+ payload = data.get("data")
191
+ if not isinstance(payload, dict):
192
+ return
193
+ for result in payload.values():
194
+ if not isinstance(result, dict):
195
+ continue
196
+ errors = _parse_error_entries(result.get("errors"))
197
+ if errors or result.get("success") is False:
198
+ if not errors:
199
+ errors = [{"code": None, "message": "The API reported success=false", "context": {}}]
200
+ raise ValidationError(
201
+ "API request failed",
202
+ status_code=response.status_code,
203
+ response_body=response.text,
204
+ errors=errors,
205
+ )
206
+
207
+ # --- Account-level convenience methods ---
208
+
209
+ def plan_sites(self) -> list[APIObject]:
210
+ """Get supported directories for your plan."""
211
+ data = self._get("plan-sites")
212
+ items = data.get("data", {}).get("planSites") or []
213
+ return [APIObject(item) for item in items]
214
+
215
+ def subcategories(self) -> list[APIObject]:
216
+ """Get business subcategories (use the databaseId as subCategoryId when creating locations)."""
217
+ data = self._get("sub-categories")
218
+ items = data.get("data", {}).get("subcategories") or []
219
+ return [APIObject(item) for item in items]
220
+
221
+ def countries(self) -> list[APIObject]:
222
+ """Get supported countries and states (ISO codes)."""
223
+ data = self._get("countries")
224
+ items = data.get("data", {}).get("supportedCountries") or []
225
+ return [APIObject(item) for item in items]
226
+
227
+ def subscriptions(self) -> list[APIObject]:
228
+ """Get active subscriptions for the account."""
229
+ data = self._get("subscriptions")
230
+ items = data.get("data", {}).get("activeSubscriptions") or []
231
+ return [APIObject(item) for item in items]
232
+
233
+
234
+ def _parse_error_entries(raw: Any) -> list[dict[str, Any]]:
235
+ if not raw:
236
+ return []
237
+ entries: list[dict[str, Any]] = []
238
+ for item in raw if isinstance(raw, list) else [raw]:
239
+ if isinstance(item, dict):
240
+ entries.append(
241
+ {
242
+ "code": item.get("code"),
243
+ "message": item.get("message") or str(item),
244
+ "context": item.get("context") or {},
245
+ }
246
+ )
247
+ else:
248
+ entries.append({"code": None, "message": str(item), "context": {}})
249
+ return entries
250
+
251
+
252
+ _AUTH_CODES = {"SY90005", "SY90001"}
253
+ _PERMISSION_CODES = {"SY90003"}
254
+
255
+
256
+ def _exception_for_errors(errors: list[dict[str, Any]], response: requests.Response) -> APIError:
257
+ codes = {e["code"] for e in errors if e.get("code")}
258
+ kwargs: dict[str, Any] = {
259
+ "status_code": response.status_code,
260
+ "response_body": response.text,
261
+ "errors": errors,
262
+ }
263
+ if codes & _AUTH_CODES:
264
+ return AuthenticationError("Authentication failed", **kwargs)
265
+ if codes & _PERMISSION_CODES:
266
+ return PermissionDeniedError("Permission denied", **kwargs)
267
+ if codes == {"RATE_LIMITED"}:
268
+ return RateLimitError("Rate limit exceeded", **kwargs)
269
+ return APIError("API request failed", **kwargs)
listingsapi/_types.py ADDED
@@ -0,0 +1,137 @@
1
+ """Response types for the listingsAPI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Generator
6
+
7
+
8
+ class APIObject:
9
+ """API response object with attribute access. Works like a dict but with dot notation.
10
+
11
+ Example:
12
+ location = client.locations.retrieve(16808)
13
+ print(location.name) # "StarBucks New York"
14
+ print(location.city) # "New York"
15
+ print(location["stateIso"]) # "NY" (dict-style also works)
16
+ print(location.to_dict()) # raw dict
17
+ """
18
+
19
+ def __init__(self, data: dict[str, Any] | None = None):
20
+ object.__setattr__(self, "_data", data or {})
21
+
22
+ def __getattr__(self, name: str) -> Any:
23
+ try:
24
+ value = self._data[name]
25
+ except KeyError:
26
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
27
+ if isinstance(value, dict):
28
+ return APIObject(value)
29
+ if isinstance(value, list):
30
+ return [APIObject(v) if isinstance(v, dict) else v for v in value]
31
+ return value
32
+
33
+ def __getitem__(self, key: str) -> Any:
34
+ return self._data[key]
35
+
36
+ def __contains__(self, key: str) -> bool:
37
+ return key in self._data
38
+
39
+ def __iter__(self):
40
+ return iter(self._data)
41
+
42
+ def __len__(self) -> int:
43
+ return len(self._data)
44
+
45
+ def keys(self):
46
+ return self._data.keys()
47
+
48
+ def values(self):
49
+ return self._data.values()
50
+
51
+ def items(self):
52
+ return self._data.items()
53
+
54
+ def __repr__(self) -> str:
55
+ return repr(self._data)
56
+
57
+ def __str__(self) -> str:
58
+ return str(self._data)
59
+
60
+ def __bool__(self) -> bool:
61
+ return bool(self._data)
62
+
63
+ def get(self, key: str, default: Any = None) -> Any:
64
+ return self._data.get(key, default)
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ """Return the raw dict."""
68
+ return self._data
69
+
70
+
71
+ class SyncPage:
72
+ """A page of results with built-in pagination.
73
+
74
+ Example:
75
+ page = client.locations.list(first=10)
76
+ for location in page:
77
+ print(location.name)
78
+
79
+ # Auto-paginate through all results
80
+ for location in page.auto_paging_iter():
81
+ print(location.name)
82
+
83
+ # Manual pagination
84
+ if page.has_more:
85
+ next_page = page.next_page()
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ data: list[dict[str, Any]],
91
+ has_more: bool = False,
92
+ end_cursor: str | None = None,
93
+ total: int | None = None,
94
+ _fetch_next: Callable[..., "SyncPage"] | None = None,
95
+ ):
96
+ self.data: list[APIObject] = [
97
+ APIObject(item) if isinstance(item, dict) else item for item in data
98
+ ]
99
+ self.has_more = has_more
100
+ self.total = total
101
+ self._end_cursor = end_cursor
102
+ self._fetch_next = _fetch_next
103
+
104
+ def __iter__(self):
105
+ return iter(self.data)
106
+
107
+ def __len__(self) -> int:
108
+ return len(self.data)
109
+
110
+ def __bool__(self) -> bool:
111
+ return bool(self.data)
112
+
113
+ def __getitem__(self, index):
114
+ return self.data[index]
115
+
116
+ def __repr__(self) -> str:
117
+ return f"SyncPage(items={len(self.data)}, has_more={self.has_more})"
118
+
119
+ def next_page(self) -> SyncPage | None:
120
+ """Fetch the next page of results, or None if no more pages."""
121
+ if not self.has_more or not self._fetch_next:
122
+ return None
123
+ return self._fetch_next(self._end_cursor)
124
+
125
+ def auto_paging_iter(self) -> Generator[APIObject, None, None]:
126
+ """Iterate through all pages automatically.
127
+
128
+ Example:
129
+ for location in client.locations.list(first=10).auto_paging_iter():
130
+ print(location.name)
131
+ """
132
+ page: SyncPage | None = self
133
+ while page is not None:
134
+ yield from page.data
135
+ if not page.has_more:
136
+ break
137
+ page = page.next_page()
listingsapi/_utils.py ADDED
@@ -0,0 +1,15 @@
1
+ """Internal utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+
7
+
8
+ def encode_location_id(id_value: str | int) -> str:
9
+ """Convert a numeric location ID to base64-encoded form, or return as-is if already encoded."""
10
+ if isinstance(id_value, int):
11
+ return base64.b64encode(f"Location:{id_value}".encode()).decode("ascii")
12
+ s = str(id_value).strip()
13
+ if s.isdigit():
14
+ return base64.b64encode(f"Location:{s}".encode()).decode("ascii")
15
+ return s
@@ -0,0 +1,104 @@
1
+ """listingsAPI SDK exceptions. Catch specific errors for fine-grained handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ListingsAPIError(Exception):
9
+ """Base exception for all listingsAPI SDK errors."""
10
+
11
+
12
+ class APIError(ListingsAPIError):
13
+ """Raised when the API reports a failure.
14
+
15
+ Covers both non-2xx HTTP responses and error payloads the API returns with
16
+ HTTP 200 (the platform reports auth and validation failures in an errors[]
17
+ body; mutations report success=false plus errors[]).
18
+
19
+ Attributes:
20
+ status_code: HTTP status code of the response (may be 200 for
21
+ payload-level errors).
22
+ response_body: Raw response text from the API, when available.
23
+ code: The first API error code (e.g. "SY10005"), when the API sent one.
24
+ errors: Parsed API error entries, each normalized to a dict with
25
+ "code", "message", and "context" keys.
26
+
27
+ Example:
28
+ try:
29
+ client.locations.list()
30
+ except listingsapi.APIError as e:
31
+ print(e.status_code, e.code)
32
+ for err in e.errors:
33
+ print(err["code"], err["message"])
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ status_code: int | None = None,
40
+ response_body: str | None = None,
41
+ errors: list[dict[str, Any]] | None = None,
42
+ ):
43
+ self.status_code = status_code
44
+ self.response_body = response_body
45
+ self.errors = errors or []
46
+ self.code = self.errors[0].get("code") if self.errors else None
47
+ detail = "; ".join(
48
+ f"{e['code']}: {e['message']}" if e.get("code") else str(e.get("message", ""))
49
+ for e in self.errors
50
+ )
51
+ if detail:
52
+ full = f"{message} — {detail}"
53
+ elif response_body:
54
+ full = f"{message} — {response_body}"
55
+ else:
56
+ full = message
57
+ super().__init__(full)
58
+
59
+
60
+ class AuthenticationError(APIError):
61
+ """401 — Invalid or missing API key."""
62
+
63
+
64
+ class PermissionDeniedError(APIError):
65
+ """403 — Insufficient permissions for this action."""
66
+
67
+
68
+ class NotFoundError(APIError):
69
+ """404 — Resource not found."""
70
+
71
+
72
+ class ValidationError(APIError):
73
+ """400/422 — Invalid request parameters."""
74
+
75
+
76
+ class RateLimitError(APIError):
77
+ """429 — Too many requests. Check retry_after for backoff.
78
+
79
+ Attributes:
80
+ retry_after: Seconds to wait before retrying (from Retry-After header), or None.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ message: str,
86
+ status_code: int | None = None,
87
+ response_body: str | None = None,
88
+ retry_after: str | None = None,
89
+ errors: list[dict[str, Any]] | None = None,
90
+ ):
91
+ super().__init__(message, status_code=status_code, response_body=response_body, errors=errors)
92
+ self.retry_after = float(retry_after) if retry_after else None
93
+
94
+
95
+ class InternalServerError(APIError):
96
+ """5xx — Server-side error. Safe to retry."""
97
+
98
+
99
+ class APIConnectionError(ListingsAPIError):
100
+ """Network failure — could not reach the API."""
101
+
102
+
103
+ # Backward compat
104
+ APIError = APIError
@@ -0,0 +1,21 @@
1
+ """listingsAPI resources."""
2
+
3
+ from listingsapi.resources.locations import Locations
4
+ from listingsapi.resources.posts import Posts
5
+ from listingsapi.resources.reviews import Reviews
6
+ from listingsapi.resources.listings import Listings
7
+ from listingsapi.resources.analytics import Analytics
8
+ from listingsapi.resources.connected_accounts import ConnectedAccounts
9
+ from listingsapi.resources.photos import Photos
10
+ from listingsapi.resources.workflows import Workflows
11
+
12
+ __all__ = [
13
+ "Locations",
14
+ "Posts",
15
+ "Reviews",
16
+ "Listings",
17
+ "Analytics",
18
+ "ConnectedAccounts",
19
+ "Photos",
20
+ "Workflows",
21
+ ]
@@ -0,0 +1,29 @@
1
+ """Base class for API resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from listingsapi._client import ListingsAPI
9
+
10
+
11
+ class APIResource:
12
+ """Base class that all resources inherit from. Provides access to the client's HTTP methods."""
13
+
14
+ def __init__(self, client: ListingsAPI) -> None:
15
+ self._client = client
16
+
17
+ def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
18
+ return self._client._get(path, params)
19
+
20
+ def _post(self, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
21
+ return self._client._post(path, json_body)
22
+
23
+ def _delete(self, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
24
+ return self._client._delete(path, json_body)
25
+
26
+ def _location_get(
27
+ self, location_id: str | int, path_suffix: str, params: dict | None = None
28
+ ) -> dict[str, Any]:
29
+ return self._client._location_get(location_id, path_suffix, params)
@@ -0,0 +1,54 @@
1
+ """Analytics resource — client.analytics.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from listingsapi._types import APIObject
8
+ from listingsapi.resources._base import APIResource
9
+
10
+
11
+ class Analytics(APIResource):
12
+ """Profile and ranking analytics.
13
+
14
+ Example:
15
+ google = client.analytics.google(16808, from_date="2024-01-01")
16
+ bing = client.analytics.bing(16808)
17
+ """
18
+
19
+ def google(
20
+ self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
21
+ ) -> APIObject:
22
+ """Get Google (GMB) profile analytics for a location."""
23
+ params: dict[str, str] = {}
24
+ if from_date:
25
+ params["fromDate"] = from_date
26
+ if to_date:
27
+ params["toDate"] = to_date
28
+ data = self._location_get(location_id, "google-analytics", params)
29
+ return APIObject(data.get("data", {}).get("googleInsights") or {})
30
+
31
+ def bing(
32
+ self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
33
+ ) -> APIObject:
34
+ """Get Bing profile analytics for a location."""
35
+ params: dict[str, str] = {}
36
+ if from_date:
37
+ params["fromDate"] = from_date
38
+ if to_date:
39
+ params["toDate"] = to_date
40
+ data = self._location_get(location_id, "bing-analytics", params)
41
+ return APIObject(data.get("data", {}).get("bingInsights") or {})
42
+
43
+ def facebook(
44
+ self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
45
+ ) -> APIObject:
46
+ """Get Facebook page analytics for a location."""
47
+ params: dict[str, str] = {}
48
+ if from_date:
49
+ params["fromDate"] = from_date
50
+ if to_date:
51
+ params["toDate"] = to_date
52
+ data = self._location_get(location_id, "facebook-analytics", params)
53
+ return APIObject(data.get("data", {}).get("facebookInsights") or {})
54
+