darak 0.1.0__tar.gz
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.
- darak-0.1.0/.gitignore +3 -0
- darak-0.1.0/PKG-INFO +46 -0
- darak-0.1.0/README.md +33 -0
- darak-0.1.0/darak/__init__.py +13 -0
- darak-0.1.0/darak/client.py +240 -0
- darak-0.1.0/darak/py.typed +0 -0
- darak-0.1.0/pyproject.toml +24 -0
- darak-0.1.0/tests/test_client.py +108 -0
darak-0.1.0/.gitignore
ADDED
darak-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: darak
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Darak API: Saudi real-estate listings and market data.
|
|
5
|
+
Project-URL: Documentation, https://platform.darak.app/docs
|
|
6
|
+
Project-URL: Changelog, https://platform.darak.app/changelog
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: api,darak,listings,real-estate,saudi-arabia
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# darak
|
|
15
|
+
|
|
16
|
+
The official Python client for the [Darak API](https://platform.darak.app/docs): every live rental and sale listing in Saudi Arabia, deduplicated across 13+ sources. Standard library only, Python 3.9+.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install darak
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from darak import Darak, DarakError
|
|
24
|
+
|
|
25
|
+
darak = Darak() # reads DARAK_API_KEY
|
|
26
|
+
|
|
27
|
+
page = darak.listings.search(city="riyadh", listing_type="rent", beds=3, limit=25)
|
|
28
|
+
print(page["data"][0]["price"]["yearly_sar"])
|
|
29
|
+
|
|
30
|
+
# Every result, fetching pages as you go (stops at your plan's paging depth).
|
|
31
|
+
for listing in darak.listings.search_all(city="jeddah", listing_type="sale"):
|
|
32
|
+
print(listing["id"], listing["location"]["neighborhood_name_en"])
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
darak.listings.get(1)
|
|
36
|
+
except DarakError as e:
|
|
37
|
+
print(e.status, e.code, e.request_id)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Get a key at [platform.darak.app](https://platform.darak.app).
|
|
41
|
+
|
|
42
|
+
- Lists are sent comma-separated: `property_type=["apartment", "villa"]`.
|
|
43
|
+
- `429` and `5xx` (and network errors) are retried, honoring `Retry-After`. A monthly-quota `429` isn't waited out; it raises.
|
|
44
|
+
- Methods: `listings.search / search_all / count / get / batch / price_history`, `cities.list / neighborhoods / directions`, `enums()`, `broker_listings(...)`, and `request(method, path, query, body)` for anything else.
|
|
45
|
+
|
|
46
|
+
Tests: `python3 -m unittest discover -s tests`.
|
darak-0.1.0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# darak
|
|
2
|
+
|
|
3
|
+
The official Python client for the [Darak API](https://platform.darak.app/docs): every live rental and sale listing in Saudi Arabia, deduplicated across 13+ sources. Standard library only, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install darak
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from darak import Darak, DarakError
|
|
11
|
+
|
|
12
|
+
darak = Darak() # reads DARAK_API_KEY
|
|
13
|
+
|
|
14
|
+
page = darak.listings.search(city="riyadh", listing_type="rent", beds=3, limit=25)
|
|
15
|
+
print(page["data"][0]["price"]["yearly_sar"])
|
|
16
|
+
|
|
17
|
+
# Every result, fetching pages as you go (stops at your plan's paging depth).
|
|
18
|
+
for listing in darak.listings.search_all(city="jeddah", listing_type="sale"):
|
|
19
|
+
print(listing["id"], listing["location"]["neighborhood_name_en"])
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
darak.listings.get(1)
|
|
23
|
+
except DarakError as e:
|
|
24
|
+
print(e.status, e.code, e.request_id)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Get a key at [platform.darak.app](https://platform.darak.app).
|
|
28
|
+
|
|
29
|
+
- Lists are sent comma-separated: `property_type=["apartment", "villa"]`.
|
|
30
|
+
- `429` and `5xx` (and network errors) are retried, honoring `Retry-After`. A monthly-quota `429` isn't waited out; it raises.
|
|
31
|
+
- Methods: `listings.search / search_all / count / get / batch / price_history`, `cities.list / neighborhoods / directions`, `enums()`, `broker_listings(...)`, and `request(method, path, query, body)` for anything else.
|
|
32
|
+
|
|
33
|
+
Tests: `python3 -m unittest discover -s tests`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official Python client for the Darak API (https://platform.darak.app/docs).
|
|
2
|
+
|
|
3
|
+
from darak import Darak
|
|
4
|
+
|
|
5
|
+
darak = Darak() # reads DARAK_API_KEY
|
|
6
|
+
for listing in darak.listings.search_all(city="riyadh", listing_type="rent"):
|
|
7
|
+
print(listing["id"], listing["price"]["yearly_sar"])
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import Darak, DarakError
|
|
11
|
+
|
|
12
|
+
__all__ = ["Darak", "DarakError"]
|
|
13
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The Darak API client. Standard library only: nothing else to install."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
__all__ = ["Darak", "DarakError"]
|
|
15
|
+
|
|
16
|
+
JSON = Dict[str, Any]
|
|
17
|
+
QueryValue = Union[str, int, float, bool, Sequence[Union[str, int]], None]
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.darak.app/v1"
|
|
20
|
+
# Longest Retry-After worth sleeping through; longer (a monthly quota) raises.
|
|
21
|
+
MAX_RETRY_WAIT_S = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DarakError(Exception):
|
|
25
|
+
"""A non-2xx response. Branch on ``code``; ``message`` is for humans."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
status: int,
|
|
30
|
+
type: str,
|
|
31
|
+
code: str,
|
|
32
|
+
message: str,
|
|
33
|
+
request_id: Optional[str] = None,
|
|
34
|
+
param: Optional[str] = None,
|
|
35
|
+
retry_after: Optional[float] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.status = status
|
|
39
|
+
self.type = type
|
|
40
|
+
self.code = code
|
|
41
|
+
self.message = message
|
|
42
|
+
self.request_id = request_id
|
|
43
|
+
self.param = param
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"DarakError(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_query(query: Mapping[str, QueryValue]) -> str:
|
|
51
|
+
"""Lists go comma-separated, as the API expects; None is dropped."""
|
|
52
|
+
parts = []
|
|
53
|
+
for key, value in query.items():
|
|
54
|
+
if value is None:
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
value = "true" if value else "false"
|
|
58
|
+
elif isinstance(value, (list, tuple)):
|
|
59
|
+
value = ",".join(str(v) for v in value)
|
|
60
|
+
parts.append((key, str(value)))
|
|
61
|
+
return "?" + urllib.parse.urlencode(parts) if parts else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Response:
|
|
65
|
+
def __init__(self, status: int, headers: Mapping[str, str], body: bytes) -> None:
|
|
66
|
+
self.status = status
|
|
67
|
+
self.headers = {k.lower(): v for k, v in headers.items()}
|
|
68
|
+
self.body = body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
Transport = Callable[[urllib.request.Request, float], _Response]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _urllib_transport(request: urllib.request.Request, timeout: float) -> _Response:
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 (https URL)
|
|
77
|
+
return _Response(res.status, dict(res.headers), res.read())
|
|
78
|
+
except urllib.error.HTTPError as err:
|
|
79
|
+
return _Response(err.code, dict(err.headers or {}), err.read() or b"")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Darak:
|
|
83
|
+
"""Client for the Darak API.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
api_key: Defaults to the ``DARAK_API_KEY`` environment variable.
|
|
87
|
+
base_url: Defaults to https://api.darak.app/v1.
|
|
88
|
+
max_retries: Retries for 429, 5xx and network errors (default 2).
|
|
89
|
+
timeout: Seconds per attempt (default 30).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
api_key: Optional[str] = None,
|
|
95
|
+
*,
|
|
96
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
97
|
+
max_retries: int = 2,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
transport: Optional[Transport] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
key = api_key or os.environ.get("DARAK_API_KEY")
|
|
102
|
+
if not key:
|
|
103
|
+
raise ValueError("Pass api_key or set DARAK_API_KEY. Get a key at https://platform.darak.app.")
|
|
104
|
+
self._api_key = key
|
|
105
|
+
self.base_url = base_url.rstrip("/")
|
|
106
|
+
self.max_retries = max_retries
|
|
107
|
+
self.timeout = timeout
|
|
108
|
+
self._transport = transport or _urllib_transport
|
|
109
|
+
self._sleep = time.sleep
|
|
110
|
+
self.listings = _Listings(self)
|
|
111
|
+
self.cities = _Cities(self)
|
|
112
|
+
|
|
113
|
+
# ── Low level ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
query: Optional[Mapping[str, QueryValue]] = None,
|
|
120
|
+
body: Optional[Any] = None,
|
|
121
|
+
) -> JSON:
|
|
122
|
+
"""Calls any endpoint; prefer the typed helpers."""
|
|
123
|
+
url = f"{self.base_url}{path}{encode_query(query or {})}"
|
|
124
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
125
|
+
headers = {
|
|
126
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
127
|
+
"Accept": "application/json",
|
|
128
|
+
"User-Agent": "darak-python/0.1.0",
|
|
129
|
+
}
|
|
130
|
+
if data is not None:
|
|
131
|
+
headers["Content-Type"] = "application/json"
|
|
132
|
+
|
|
133
|
+
attempt = 0
|
|
134
|
+
while True:
|
|
135
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
136
|
+
try:
|
|
137
|
+
res = self._transport(request, self.timeout)
|
|
138
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
|
139
|
+
if attempt < self.max_retries:
|
|
140
|
+
self._sleep(_backoff(attempt))
|
|
141
|
+
attempt += 1
|
|
142
|
+
continue
|
|
143
|
+
raise
|
|
144
|
+
if 200 <= res.status < 300:
|
|
145
|
+
return json.loads(res.body or b"{}")
|
|
146
|
+
|
|
147
|
+
error = _to_error(res)
|
|
148
|
+
retryable = res.status == 429 or res.status >= 500
|
|
149
|
+
wait = error.retry_after if error.retry_after is not None else _backoff(attempt)
|
|
150
|
+
if retryable and attempt < self.max_retries and wait <= MAX_RETRY_WAIT_S:
|
|
151
|
+
self._sleep(wait)
|
|
152
|
+
attempt += 1
|
|
153
|
+
continue
|
|
154
|
+
raise error
|
|
155
|
+
|
|
156
|
+
# ── Reference data ────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def enums(self) -> JSON:
|
|
159
|
+
"""Allowed values for enum parameters (sources, property types, …)."""
|
|
160
|
+
return self.request("GET", "/enums")
|
|
161
|
+
|
|
162
|
+
def broker_listings(self, **body: Any) -> JSON:
|
|
163
|
+
"""Enterprise plans: listings by broker commercial registration."""
|
|
164
|
+
return self.request("POST", "/broker-listings", body=body)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Listings:
|
|
168
|
+
def __init__(self, client: Darak) -> None:
|
|
169
|
+
self._c = client
|
|
170
|
+
|
|
171
|
+
def search(self, **params: QueryValue) -> JSON:
|
|
172
|
+
"""One page. Follow ``pagination.next_cursor``, or use ``search_all``."""
|
|
173
|
+
return self._c.request("GET", "/listings", params)
|
|
174
|
+
|
|
175
|
+
def search_all(self, **params: QueryValue) -> Iterator[JSON]:
|
|
176
|
+
"""Every matching listing, fetching pages as you iterate. Stops at the
|
|
177
|
+
end of the results or at your plan's paging depth."""
|
|
178
|
+
params.pop("cursor", None)
|
|
179
|
+
cursor: Optional[str] = None
|
|
180
|
+
while True:
|
|
181
|
+
page = self.search(**params, cursor=cursor)
|
|
182
|
+
yield from page.get("data", [])
|
|
183
|
+
pagination = page.get("pagination") or {}
|
|
184
|
+
cursor = pagination.get("next_cursor") if pagination.get("has_more") else None
|
|
185
|
+
if not cursor:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
def count(self, **params: QueryValue) -> JSON:
|
|
189
|
+
return self._c.request("GET", "/listings/count", params)
|
|
190
|
+
|
|
191
|
+
def get(self, listing_id: int) -> JSON:
|
|
192
|
+
return self._c.request("GET", f"/listings/{int(listing_id)}")
|
|
193
|
+
|
|
194
|
+
def batch(self, ids: Sequence[int]) -> JSON:
|
|
195
|
+
"""Ids that aren't live come back in ``missing_ids``."""
|
|
196
|
+
return self._c.request("GET", "/listings/batch", {"ids": list(ids)})
|
|
197
|
+
|
|
198
|
+
def price_history(self, listing_id: int) -> JSON:
|
|
199
|
+
return self._c.request("GET", f"/listings/{int(listing_id)}/price-history")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _Cities:
|
|
203
|
+
def __init__(self, client: Darak) -> None:
|
|
204
|
+
self._c = client
|
|
205
|
+
|
|
206
|
+
def list(self) -> JSON:
|
|
207
|
+
return self._c.request("GET", "/cities")
|
|
208
|
+
|
|
209
|
+
def neighborhoods(self, city: str) -> JSON:
|
|
210
|
+
return self._c.request("GET", f"/cities/{urllib.parse.quote(city)}/neighborhoods")
|
|
211
|
+
|
|
212
|
+
def directions(self, city: str) -> JSON:
|
|
213
|
+
return self._c.request("GET", f"/cities/{urllib.parse.quote(city)}/directions")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _backoff(attempt: int) -> float:
|
|
217
|
+
"""~0.5s, 1s, 2s … with jitter."""
|
|
218
|
+
return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _to_error(res: _Response) -> DarakError:
|
|
222
|
+
try:
|
|
223
|
+
payload = json.loads(res.body or b"{}").get("error") or {}
|
|
224
|
+
except ValueError:
|
|
225
|
+
payload = {}
|
|
226
|
+
retry_after: Optional[float] = None
|
|
227
|
+
if "retry-after" in res.headers:
|
|
228
|
+
try:
|
|
229
|
+
retry_after = float(res.headers["retry-after"])
|
|
230
|
+
except ValueError:
|
|
231
|
+
retry_after = None
|
|
232
|
+
return DarakError(
|
|
233
|
+
status=res.status,
|
|
234
|
+
type=payload.get("type", "api_error"),
|
|
235
|
+
code=payload.get("code", f"http_{res.status}"),
|
|
236
|
+
message=payload.get("message", f"The API returned HTTP {res.status}."),
|
|
237
|
+
request_id=payload.get("request_id") or res.headers.get("x-request-id"),
|
|
238
|
+
param=payload.get("param"),
|
|
239
|
+
retry_after=retry_after,
|
|
240
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "darak"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Darak API: Saudi real-estate listings and market data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["darak", "real-estate", "saudi-arabia", "api", "listings"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Documentation = "https://platform.darak.app/docs"
|
|
21
|
+
Changelog = "https://platform.darak.app/changelog"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["darak"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
import urllib.error
|
|
5
|
+
|
|
6
|
+
from darak import Darak, DarakError
|
|
7
|
+
from darak.client import _Response, encode_query
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeTransport:
|
|
11
|
+
"""Answers from a queue and records the requests."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *replies):
|
|
14
|
+
self.replies = list(replies)
|
|
15
|
+
self.requests = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, request, timeout):
|
|
18
|
+
self.requests.append(request)
|
|
19
|
+
reply = self.replies.pop(0) if self.replies else (200, {}, {})
|
|
20
|
+
if isinstance(reply, Exception):
|
|
21
|
+
raise reply
|
|
22
|
+
status, headers, body = reply
|
|
23
|
+
return _Response(status, headers, json.dumps(body).encode())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def client(transport, **kw):
|
|
27
|
+
c = Darak("dk_live_test", base_url="https://api.test/v1", transport=transport, **kw)
|
|
28
|
+
c._sleep = lambda s: None # no real waiting in tests
|
|
29
|
+
return c
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EncodeQueryTest(unittest.TestCase):
|
|
33
|
+
def test_lists_bools_and_none(self):
|
|
34
|
+
self.assertEqual(
|
|
35
|
+
encode_query({"city": "riyadh", "property_type": ["apartment", "villa"], "furnished": True, "beds": None}),
|
|
36
|
+
"?city=riyadh&property_type=apartment%2Cvilla&furnished=true",
|
|
37
|
+
)
|
|
38
|
+
self.assertEqual(encode_query({}), "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClientTest(unittest.TestCase):
|
|
42
|
+
def test_sends_bearer_key_and_parses_json(self):
|
|
43
|
+
t = FakeTransport((200, {}, {"data": [{"slug": "riyadh"}]}))
|
|
44
|
+
self.assertEqual(client(t).cities.list(), {"data": [{"slug": "riyadh"}]})
|
|
45
|
+
self.assertEqual(t.requests[0].full_url, "https://api.test/v1/cities")
|
|
46
|
+
self.assertEqual(t.requests[0].get_header("Authorization"), "Bearer dk_live_test")
|
|
47
|
+
|
|
48
|
+
def test_needs_a_key(self):
|
|
49
|
+
saved = os.environ.pop("DARAK_API_KEY", None)
|
|
50
|
+
try:
|
|
51
|
+
with self.assertRaises(ValueError):
|
|
52
|
+
Darak()
|
|
53
|
+
finally:
|
|
54
|
+
if saved is not None:
|
|
55
|
+
os.environ["DARAK_API_KEY"] = saved
|
|
56
|
+
|
|
57
|
+
def test_retries_429_and_5xx(self):
|
|
58
|
+
t = FakeTransport(
|
|
59
|
+
(429, {"Retry-After": "2"}, {"error": {"code": "rate_limited"}}),
|
|
60
|
+
(503, {}, {}),
|
|
61
|
+
(200, {}, {"data": []}),
|
|
62
|
+
)
|
|
63
|
+
waits = []
|
|
64
|
+
c = client(t)
|
|
65
|
+
c._sleep = waits.append
|
|
66
|
+
self.assertEqual(c.enums(), {"data": []})
|
|
67
|
+
self.assertEqual(len(t.requests), 3)
|
|
68
|
+
self.assertEqual(waits[0], 2.0)
|
|
69
|
+
|
|
70
|
+
def test_does_not_wait_out_a_monthly_quota(self):
|
|
71
|
+
t = FakeTransport(
|
|
72
|
+
(
|
|
73
|
+
429,
|
|
74
|
+
{"Retry-After": "86400", "X-Request-Id": "req_1"},
|
|
75
|
+
{"error": {"type": "rate_limit_error", "code": "monthly_quota_exceeded", "message": "over"}},
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
with self.assertRaises(DarakError) as ctx:
|
|
79
|
+
client(t).enums()
|
|
80
|
+
e = ctx.exception
|
|
81
|
+
self.assertEqual((e.status, e.code, e.request_id, e.retry_after), (429, "monthly_quota_exceeded", "req_1", 86400.0))
|
|
82
|
+
self.assertEqual(len(t.requests), 1)
|
|
83
|
+
|
|
84
|
+
def test_client_errors_are_not_retried(self):
|
|
85
|
+
t = FakeTransport((400, {}, {"error": {"type": "invalid_request", "code": "unknown_parameter", "param": "bed"}}))
|
|
86
|
+
with self.assertRaises(DarakError) as ctx:
|
|
87
|
+
client(t).listings.count(city="riyadh", listing_type="rent", bed=2)
|
|
88
|
+
self.assertEqual(ctx.exception.param, "bed")
|
|
89
|
+
self.assertEqual(len(t.requests), 1)
|
|
90
|
+
|
|
91
|
+
def test_network_errors_retry_then_raise(self):
|
|
92
|
+
t = FakeTransport(urllib.error.URLError("down"), urllib.error.URLError("down"))
|
|
93
|
+
with self.assertRaises(urllib.error.URLError):
|
|
94
|
+
client(t, max_retries=1).enums()
|
|
95
|
+
self.assertEqual(len(t.requests), 2)
|
|
96
|
+
|
|
97
|
+
def test_search_all_follows_cursors(self):
|
|
98
|
+
def page(ids, nxt):
|
|
99
|
+
return (200, {}, {"data": [{"id": i} for i in ids], "pagination": {"next_cursor": nxt, "has_more": nxt is not None}})
|
|
100
|
+
|
|
101
|
+
t = FakeTransport(page([1, 2], "c2"), page([3], None))
|
|
102
|
+
ids = [l["id"] for l in client(t).listings.search_all(city="riyadh", listing_type="rent", limit=2)]
|
|
103
|
+
self.assertEqual(ids, [1, 2, 3])
|
|
104
|
+
self.assertIn("cursor=c2", t.requests[1].full_url)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|