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 ADDED
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
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()