kite-sa 0.3.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.
kite_sa/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Official Python client for the Kite API (https://platform.kite.sa/docs).
2
+
3
+ from kite_sa import Kite
4
+
5
+ kite = Kite() # reads KITE_API_KEY
6
+ for listing in kite.listings.search_all(city="riyadh", listing_type="rent"):
7
+ print(listing["id"], listing["price"]["yearly_sar"])
8
+ """
9
+
10
+ from .client import Kite, KiteError
11
+
12
+ __all__ = ["Kite", "KiteError"]
13
+ __version__ = "0.3.0"
kite_sa/_generated.py ADDED
@@ -0,0 +1,455 @@
1
+ # Generated from src/lib/api-v1/openapi.v1.json (spec 1a42b6a863454caf).
2
+ # Do not edit: run `python packages/sdk-python/scripts/generate.py`.
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional, Union
6
+ from urllib.parse import quote
7
+
8
+ JSON = Any
9
+ QueryValue = Any
10
+
11
+
12
+ def _idempotency_headers(key: Optional[str]) -> Optional[Dict[str, str]]:
13
+ return {"Idempotency-Key": key} if key else None
14
+
15
+
16
+ class _Analytics:
17
+ def __init__(self, client: Any) -> None:
18
+ self._c = client
19
+
20
+ def deals(self, **params: QueryValue) -> JSON:
21
+ """
22
+ Find below-market listings. Residential listings asking noticeably less than
23
+ similar listings nearby, biggest gap first. Each listing is compared with
24
+ Darak's published nearby asking-price comparison: the same property type and
25
+ bedroom count, a similar area, within a calibrated radius and look-back
26
+ (disclosed per listing in `comparison`), one observation per distinct unit, and
27
+ the subject's own duplicates excluded
28
+ """
29
+ return self._c.request("GET", "/market/deals", params)
30
+
31
+ def listing_comparables(self, id: Union[int, str], **params: QueryValue) -> JSON:
32
+ """
33
+ Get comparable listings. Active listings similar to this one, nearest first,
34
+ with how each one differs. A comparable has the same city, listing type and
35
+ property type, is within about 5 km, and has an area between 2/3 and 1.5 times
36
+ this listing's (the comparison-approach caliper)
37
+ """
38
+ return self._c.request("GET", f"/listings/{quote(str(id))}/comparables", params)
39
+
40
+ def listing_market_position(self, id: Union[int, str]) -> JSON:
41
+ """
42
+ Get a listing's market position. Where a listing's price sits in its market:
43
+ percentile, quartiles and difference from the median, against like-for-like
44
+ listings in its neighborhood and in its city. The cohort is listings with the
45
+ same listing type, category and property type, and for residential listings the
46
+ same bedroom count
47
+ """
48
+ return self._c.request("GET", f"/listings/{quote(str(id))}/market-position")
49
+
50
+ def listing_price_band(self, id: Union[int, str]) -> JSON:
51
+ """
52
+ Get a listing's nearby asking-price band. The 25th percentile, median and 75th
53
+ percentile of asking prices for comparable units near this listing, in SAR and
54
+ per m², where this listing's price sits against them, and the cohort and
55
+ stability behind the numbers
56
+ """
57
+ return self._c.request("GET", f"/listings/{quote(str(id))}/price-band")
58
+
59
+ def neighborhoods_compare(self, **params: QueryValue) -> JSON:
60
+ """
61
+ Compare neighborhoods. Side-by-side profile of 2 to 5 neighborhoods: price
62
+ quartiles, area, price per m², amenity shares, bedroom and property-type mix,
63
+ and for rentals the gross yield against the same cohort's sale prices
64
+ """
65
+ return self._c.request("GET", "/market/neighborhoods/compare", params)
66
+
67
+ def rental_yield(self, **params: QueryValue) -> JSON:
68
+ """
69
+ Get gross rental yield. Gross rental yield (median yearly asking rent ÷ median
70
+ asking sale price) for one property type, over the whole city (or the requested
71
+ neighborhoods) and per neighborhood, highest yield first. Rent and sale medians
72
+ come from the same cohort (property type, and bedrooms if given) in the same
73
+ area
74
+ """
75
+ return self._c.request("GET", "/market/rental-yield", params)
76
+
77
+ def trends(self, **params: QueryValue) -> JSON:
78
+ """
79
+ Get price trends. Monthly median asking price over recent months (up to 36), for
80
+ a city or for up to five neighborhoods (one series each). Computed from Darak's
81
+ listing history, not from today's listings: every listing Darak has recorded,
82
+ including ones since rented, sold or removed, and every price change (the price
83
+ in force at each point in time, not the current one). - `on_market` (default): a
84
+ listing counts in a month if it was live at any point that month (first seen
85
+ before the month ended, and still active or last seen on or after the month
86
+ started), at the price it had at the end of the month, or when last seen if it
87
+ went off the market earlier. - `new_listings`: a listing counts in the month
88
+ Darak first saw it, at its first recorded asking price. Listings are
89
+ deduplicated across sources and pass the same outlier filter as the current
90
+ market (fences are today's), but not the 30-day freshness window, which doesn't
91
+ apply to history
92
+ """
93
+ return self._c.request("GET", "/market/trends", params)
94
+
95
+
96
+ class _Exports:
97
+ def __init__(self, client: Any) -> None:
98
+ self._c = client
99
+
100
+ def create(self, *, idempotency_key: Optional[str] = None, **body: Any) -> JSON:
101
+ """
102
+ Create an export. Starts an export of a whole dataset as files, for loading into
103
+ a warehouse
104
+ """
105
+ return self._c.request("POST", "/exports", body=body, headers=_idempotency_headers(idempotency_key))
106
+
107
+ def get(self, export_id: Union[int, str]) -> JSON:
108
+ """
109
+ Get an export. An export's status and, once `completed`, a signed download link
110
+ for every file, valid for 60 minutes; call again for fresh links
111
+ """
112
+ return self._c.request("GET", f"/exports/{quote(str(export_id))}")
113
+
114
+ def list(self, **params: QueryValue) -> JSON:
115
+ """
116
+ List exports. Your organization's exports, newest first, without download links
117
+ (GET one export for those)
118
+ """
119
+ return self._c.request("GET", "/exports", params)
120
+
121
+
122
+ class _Listings:
123
+ def __init__(self, client: Any) -> None:
124
+ self._c = client
125
+
126
+ def batch(self, **params: QueryValue) -> JSON:
127
+ """Get listings by id. Up to 100 listings in one call, in the order requested"""
128
+ return self._c.request("GET", "/listings/batch", params)
129
+
130
+ def count(self, **params: QueryValue) -> JSON:
131
+ """Count listings. How many listings match the same filters as search."""
132
+ return self._c.request("GET", "/listings/count", params)
133
+
134
+ def get(self, id: Union[int, str]) -> JSON:
135
+ """Get a listing. One active listing"""
136
+ return self._c.request("GET", f"/listings/{quote(str(id))}")
137
+
138
+ def price_history(self, id: Union[int, str]) -> JSON:
139
+ """
140
+ Get a listing's price history. Every price change Darak recorded for an active
141
+ listing, oldest first
142
+ """
143
+ return self._c.request("GET", f"/listings/{quote(str(id))}/price-history")
144
+
145
+ def search(self, **params: QueryValue) -> JSON:
146
+ """
147
+ Search listings. Active, deduplicated listings in one city, quality-filtered
148
+ like darak.app
149
+ """
150
+ return self._c.request("GET", "/listings", params)
151
+
152
+
153
+ class _Market:
154
+ def __init__(self, client: Any) -> None:
155
+ self._c = client
156
+
157
+ def area_distribution(self, **params: QueryValue) -> JSON:
158
+ """
159
+ Area distribution. Histogram and percentiles of floor/plot area (m²) over
160
+ listings in the sample that state an area (`without_area_count` did not)
161
+ """
162
+ return self._c.request("GET", "/market/area-distribution", params)
163
+
164
+ def land_prices(self, **params: QueryValue) -> JSON:
165
+ """
166
+ Land prices per m². Median registered price per m² of land plots, per quarter,
167
+ for a city or for each of up to 20 neighborhoods, with quarter-on-quarter and
168
+ year-on-year change
169
+ """
170
+ return self._c.request("GET", "/market/land-prices", params)
171
+
172
+ def neighborhoods(self, **params: QueryValue) -> JSON:
173
+ """
174
+ Prices by neighborhood. Median and quartile price per neighborhood in a city,
175
+ most expensive first
176
+ """
177
+ return self._c.request("GET", "/market/neighborhoods", params)
178
+
179
+ def price_distribution(self, **params: QueryValue) -> JSON:
180
+ """Price distribution. Histogram and percentiles of price"""
181
+ return self._c.request("GET", "/market/price-distribution", params)
182
+
183
+ def summary(self, **params: QueryValue) -> JSON:
184
+ """
185
+ Market summary. Headline numbers for a city (or neighborhoods): listing count,
186
+ median and quartile price, median area and price per m², how recently listings
187
+ were updated, and breakdowns by property type, top neighborhoods by volume, and
188
+ source. Computed over the market sample: active, deduplicated listings updated
189
+ in the last 30 days (`updated_at`), after the same outlier filter darak.app uses
190
+ (per-tier price fences from the median and MAD of log price, bedroom and area
191
+ sanity checks, and a floor at 30% of the neighborhood median)
192
+ """
193
+ return self._c.request("GET", "/market/summary", params)
194
+
195
+ def supply(self, **params: QueryValue) -> JSON:
196
+ """
197
+ New supply. How many listings came onto the market recently: listings first seen
198
+ in the last 7 days vs the 7 days before, and the last 30 days vs the 30 days
199
+ before (rolling windows ending now)
200
+ """
201
+ return self._c.request("GET", "/market/supply", params)
202
+
203
+ def transactions(self, **params: QueryValue) -> JSON:
204
+ """
205
+ Registered deal statistics. Deal counts and median, 25th and 75th percentile
206
+ price per m² and total price, per quarter and land-use class, for a city, a set
207
+ of neighborhoods combined (`group_by=city` with `neighborhood_id`), or each
208
+ neighborhood (`group_by=neighborhood`)
209
+ """
210
+ return self._c.request("GET", "/market/transactions", params)
211
+
212
+ def vacancy(self, **params: QueryValue) -> JSON:
213
+ """
214
+ Vacancy indicator. How long listings in the sample have been on the market, as a
215
+ proxy for vacancy (actual occupancy isn't observed)
216
+ """
217
+ return self._c.request("GET", "/market/vacancy", params)
218
+
219
+
220
+ class _Official:
221
+ def __init__(self, client: Any) -> None:
222
+ self._c = client
223
+
224
+ def market_index(self, **params: QueryValue) -> JSON:
225
+ """
226
+ Darak Property Index. **Free on every plan (0 units).** Series built on Saudi
227
+ open data are served under the Saudi Open Data License v2.0 with attribution:
228
+ show `attribution.notice` wherever you display or pass on those figures
229
+ """
230
+ return self._c.request("GET", "/market/property-index", params)
231
+
232
+ def registered_rents(self, **params: QueryValue) -> JSON:
233
+ """
234
+ Registered rents (REGA). **Free on every plan (0 units).** This is Saudi open
235
+ data, served at no charge under the Saudi Open Data License v2.0 with
236
+ attribution: show `attribution.notice` wherever you display or pass on the
237
+ figures. Registered rents from REGA's quarterly rent index ("مؤشر الايجار",
238
+ published on open.data.gov.sa): for each neighborhood and property type, the
239
+ **mean** yearly rent of the residential leases registered on Ejar that quarter,
240
+ the mean yearly rent per m², and the number of contracts behind them. - **These
241
+ are means, not medians.** REGA publishes the mean; a few large leases pull it
242
+ up, so it usually sits above a median
243
+ """
244
+ return self._c.request("GET", "/market/registered-rents", params)
245
+
246
+
247
+ class _OrganizationAuditEvents:
248
+ def __init__(self, client: Any) -> None:
249
+ self._c = client
250
+
251
+ def list(self, **params: QueryValue) -> JSON:
252
+ """List audit events. Newest first, 50 per page"""
253
+ return self._c.request("GET", "/organization/audit-events", params)
254
+
255
+
256
+ class _OrganizationKeys:
257
+ def __init__(self, client: Any) -> None:
258
+ self._c = client
259
+
260
+ def create(self, *, idempotency_key: Optional[str] = None, **body: Any) -> JSON:
261
+ """Create a data key. Creates a `dk_live_…` key"""
262
+ return self._c.request("POST", "/organization/keys", body=body, headers=_idempotency_headers(idempotency_key))
263
+
264
+ def list(self) -> JSON:
265
+ """List data keys. Active data keys (`dk_live_…`)"""
266
+ return self._c.request("GET", "/organization/keys")
267
+
268
+ def revoke(self, key_id: Union[int, str], *, idempotency_key: Optional[str] = None) -> JSON:
269
+ """Revoke a key. Requests with the key are rejected right away"""
270
+ return self._c.request("POST", f"/organization/keys/{quote(str(key_id))}/revoke", headers=_idempotency_headers(idempotency_key))
271
+
272
+
273
+ class _OrganizationMembers:
274
+ def __init__(self, client: Any) -> None:
275
+ self._c = client
276
+
277
+ def list(self) -> JSON:
278
+ """
279
+ List members and invitations. Everyone with access to this organization and
280
+ every invitation not yet accepted, with each person's role.
281
+ """
282
+ return self._c.request("GET", "/organization/members")
283
+
284
+
285
+ class _OrganizationProjects:
286
+ def __init__(self, client: Any) -> None:
287
+ self._c = client
288
+
289
+ def list(self) -> JSON:
290
+ """List projects. The projects this organization's keys belong to"""
291
+ return self._c.request("GET", "/organization/projects")
292
+
293
+
294
+ class _OrganizationRequestLogs:
295
+ def __init__(self, client: Any) -> None:
296
+ self._c = client
297
+
298
+ def list(self, **params: QueryValue) -> JSON:
299
+ """
300
+ List request logs. Calls this organization made, newest first, within its
301
+ retention window (7, 30 or 90 days, set on the Settings page)
302
+ """
303
+ return self._c.request("GET", "/organization/request-logs", params)
304
+
305
+
306
+ class _OrganizationWebhooks:
307
+ def __init__(self, client: Any) -> None:
308
+ self._c = client
309
+
310
+ def create(self, *, idempotency_key: Optional[str] = None, **body: Any) -> JSON:
311
+ """Create a webhook endpoint. Subscribes an HTTPS URL to listing events"""
312
+ return self._c.request("POST", "/organization/webhooks", body=body, headers=_idempotency_headers(idempotency_key))
313
+
314
+ def delete(self, webhook_id: Union[int, str], *, idempotency_key: Optional[str] = None) -> JSON:
315
+ """
316
+ Delete a webhook endpoint. Stops delivery and removes the endpoint and its
317
+ delivery history.
318
+ """
319
+ return self._c.request("POST", f"/organization/webhooks/{quote(str(webhook_id))}/delete", headers=_idempotency_headers(idempotency_key))
320
+
321
+ def deliveries(self, webhook_id: Union[int, str], **params: QueryValue) -> JSON:
322
+ """
323
+ List deliveries. What was sent to this endpoint, newest first, with what it
324
+ answered
325
+ """
326
+ return self._c.request("GET", f"/organization/webhooks/{quote(str(webhook_id))}/deliveries", params)
327
+
328
+ def list(self) -> JSON:
329
+ """List webhook endpoints. Every endpoint this organization has subscribed"""
330
+ return self._c.request("GET", "/organization/webhooks")
331
+
332
+ def replay(self, webhook_id: Union[int, str], delivery_id: Union[int, str], *, idempotency_key: Optional[str] = None) -> JSON:
333
+ """
334
+ Replay a delivery. Queues the same payload again, with its original `event_id`
335
+ so a receiver that already handled it can recognise the repeat
336
+ """
337
+ return self._c.request("POST", f"/organization/webhooks/{quote(str(webhook_id))}/deliveries/{quote(str(delivery_id))}/replay", headers=_idempotency_headers(idempotency_key))
338
+
339
+
340
+ class _Organization:
341
+ def __init__(self, client: Any) -> None:
342
+ self._c = client
343
+ self.audit_events = _OrganizationAuditEvents(client)
344
+ self.keys = _OrganizationKeys(client)
345
+ self.members = _OrganizationMembers(client)
346
+ self.projects = _OrganizationProjects(client)
347
+ self.request_logs = _OrganizationRequestLogs(client)
348
+ self.webhooks = _OrganizationWebhooks(client)
349
+
350
+ def get(self) -> JSON:
351
+ """Get your organization. Name, plan and limits, and this month's usage."""
352
+ return self._c.request("GET", "/organization")
353
+
354
+ def usage(self, **params: QueryValue) -> JSON:
355
+ """
356
+ Daily usage. Requests and units per UTC day, key, endpoint and result class, for
357
+ the last 7, 30 or 90 days.
358
+ """
359
+ return self._c.request("GET", "/organization/usage", params)
360
+
361
+
362
+ class _Projects:
363
+ def __init__(self, client: Any) -> None:
364
+ self._c = client
365
+
366
+ def developers(self, **params: QueryValue) -> JSON:
367
+ """List developers. Developers with active projects on Darak, most projects first"""
368
+ return self._c.request("GET", "/developers", params)
369
+
370
+ def get(self, id: Union[int, str]) -> JSON:
371
+ """
372
+ Get a project. One project with its description, boundary, amenities, financing
373
+ banks and a summary of the units listed on Darak
374
+ """
375
+ return self._c.request("GET", f"/projects/{quote(str(id))}")
376
+
377
+ def search(self, **params: QueryValue) -> JSON:
378
+ """
379
+ Search projects. Off-plan and newly built residential and commercial projects
380
+ from Saudi developers and portals, one entry per project (copies from several
381
+ sources are merged)
382
+ """
383
+ return self._c.request("GET", "/projects", params)
384
+
385
+ def units(self, id: Union[int, str], **params: QueryValue) -> JSON:
386
+ """
387
+ List a project's units. Active listings on Darak linked to one project: units
388
+ for sale (total price) and, for some projects, rentals (yearly rent)
389
+ """
390
+ return self._c.request("GET", f"/projects/{quote(str(id))}/units", params)
391
+
392
+ def units_search(self, **params: QueryValue) -> JSON:
393
+ """
394
+ Search project units. Units across all projects: active listings linked to a
395
+ project, filtered by the unit (bedrooms, price, area) and by its project (city,
396
+ developer, status)
397
+ """
398
+ return self._c.request("GET", "/project-units", params)
399
+
400
+
401
+ class _Reference:
402
+ def __init__(self, client: Any) -> None:
403
+ self._c = client
404
+
405
+ def cities(self) -> JSON:
406
+ """List cities. Cities Darak covers"""
407
+ return self._c.request("GET", "/cities")
408
+
409
+ def directions(self, city: Union[int, str]) -> JSON:
410
+ """
411
+ List city directions. Groupings of neighborhoods by part of the city (north,
412
+ east, …)
413
+ """
414
+ return self._c.request("GET", f"/cities/{quote(str(city))}/directions")
415
+
416
+ def enums(self) -> JSON:
417
+ """List enumerated values. Allowed values for enumerated fields and filters"""
418
+ return self._c.request("GET", "/enums")
419
+
420
+ def limits(self) -> JSON:
421
+ """
422
+ Get this key's limits. The plan, rate limit, monthly quota, page size and paging
423
+ depth that apply to the key making the request, and the APIs it can reach
424
+ """
425
+ return self._c.request("GET", "/limits")
426
+
427
+ def neighborhoods(self, city: Union[int, str]) -> JSON:
428
+ """List neighborhoods. Active neighborhoods in a city"""
429
+ return self._c.request("GET", f"/cities/{quote(str(city))}/neighborhoods")
430
+
431
+
432
+ class _Transactions:
433
+ def __init__(self, client: Any) -> None:
434
+ self._c = client
435
+
436
+ def search(self, **params: QueryValue) -> JSON:
437
+ """
438
+ Search registered deals. Individual sale deals registered with the Ministry of
439
+ Justice, from its quarterly open data, in one city: month, neighborhood, land-
440
+ use class, area, price and price per m²
441
+ """
442
+ return self._c.request("GET", "/transactions", params)
443
+
444
+
445
+ def attach(client: Any) -> None:
446
+ """Attaches every namespace in the spec to the client."""
447
+ client.analytics = _Analytics(client)
448
+ client.exports = _Exports(client)
449
+ client.listings = _Listings(client)
450
+ client.market = _Market(client)
451
+ client.official = _Official(client)
452
+ client.organization = _Organization(client)
453
+ client.projects = _Projects(client)
454
+ client.reference = _Reference(client)
455
+ client.transactions = _Transactions(client)
kite_sa/client.py ADDED
@@ -0,0 +1,250 @@
1
+ """The Kite 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
+ import uuid
13
+ from ._generated import attach as _attach_endpoints, _Listings as _ListingsGenerated, _Reference
14
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
15
+
16
+ __all__ = ["Kite", "KiteError"]
17
+
18
+ JSON = Dict[str, Any]
19
+ QueryValue = Union[str, int, float, bool, Sequence[Union[str, int]], None]
20
+
21
+ DEFAULT_BASE_URL = "https://api.kite.sa/v1"
22
+
23
+
24
+ def _user_agent() -> str:
25
+ """Read from the package rather than hardcoded, which drifted once already."""
26
+ from . import __version__
27
+
28
+ return f"kite-sa-python/{__version__}"
29
+ # Longest Retry-After worth sleeping through; longer (a monthly quota) raises.
30
+ MAX_RETRY_WAIT_S = 60
31
+
32
+
33
+ class KiteError(Exception):
34
+ """A non-2xx response. Branch on ``code``; ``message`` is for humans."""
35
+
36
+ def __init__(
37
+ self,
38
+ status: int,
39
+ type: str,
40
+ code: str,
41
+ message: str,
42
+ request_id: Optional[str] = None,
43
+ param: Optional[str] = None,
44
+ retry_after: Optional[float] = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+ self.status = status
48
+ self.type = type
49
+ self.code = code
50
+ self.message = message
51
+ self.request_id = request_id
52
+ self.param = param
53
+ self.retry_after = retry_after
54
+
55
+ def __repr__(self) -> str:
56
+ return f"KiteError(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
57
+
58
+
59
+ def encode_query(query: Mapping[str, QueryValue]) -> str:
60
+ """Lists go comma-separated, as the API expects; None is dropped."""
61
+ parts = []
62
+ for key, value in query.items():
63
+ if value is None:
64
+ continue
65
+ if isinstance(value, bool):
66
+ value = "true" if value else "false"
67
+ elif isinstance(value, (list, tuple)):
68
+ value = ",".join(str(v) for v in value)
69
+ parts.append((key, str(value)))
70
+ return "?" + urllib.parse.urlencode(parts) if parts else ""
71
+
72
+
73
+ class _Response:
74
+ def __init__(self, status: int, headers: Mapping[str, str], body: bytes) -> None:
75
+ self.status = status
76
+ self.headers = {k.lower(): v for k, v in headers.items()}
77
+ self.body = body
78
+
79
+
80
+ Transport = Callable[[urllib.request.Request, float], _Response]
81
+
82
+
83
+ def _urllib_transport(request: urllib.request.Request, timeout: float) -> _Response:
84
+ try:
85
+ with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 (https URL)
86
+ return _Response(res.status, dict(res.headers), res.read())
87
+ except urllib.error.HTTPError as err:
88
+ return _Response(err.code, dict(err.headers or {}), err.read() or b"")
89
+
90
+
91
+ class Kite:
92
+ """Client for the Kite API.
93
+
94
+ Args:
95
+ api_key: Defaults to the ``KITE_API_KEY`` environment variable.
96
+ base_url: Defaults to https://api.kite.sa/v1.
97
+ max_retries: Retries for 429, 5xx and network errors (default 2).
98
+ timeout: Seconds per attempt (default 30).
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ api_key: Optional[str] = None,
104
+ *,
105
+ base_url: str = DEFAULT_BASE_URL,
106
+ max_retries: int = 2,
107
+ timeout: float = 30.0,
108
+ transport: Optional[Transport] = None,
109
+ ) -> None:
110
+ key = api_key or os.environ.get("KITE_API_KEY")
111
+ if not key:
112
+ raise ValueError("Pass api_key or set KITE_API_KEY. Get a key at https://platform.kite.sa.")
113
+ self._api_key = key
114
+ self.base_url = base_url.rstrip("/")
115
+ self.max_retries = max_retries
116
+ self.timeout = timeout
117
+ self._transport = transport or _urllib_transport
118
+ self._sleep = time.sleep
119
+ # Every endpoint in the spec, generated. A new one appears here as
120
+ # soon as it appears in the spec — the hand-written surface silently
121
+ # covered nine of thirty-four and nothing failed to say so.
122
+ _attach_endpoints(self)
123
+ # `listings` gains search_all on top of the generated methods, and the
124
+ # names the 0.1.0 client used keep working.
125
+ self.listings = _Listings(self)
126
+ self.cities = _Cities(self)
127
+
128
+ # ── Low level ─────────────────────────────────────────────────────────
129
+
130
+ def request(
131
+ self,
132
+ method: str,
133
+ path: str,
134
+ query: Optional[Mapping[str, QueryValue]] = None,
135
+ body: Optional[Any] = None,
136
+ headers: Optional[Mapping[str, str]] = None,
137
+ ) -> JSON:
138
+ """Calls any endpoint; prefer the typed helpers.
139
+
140
+ A POST without an ``Idempotency-Key`` header gets a fresh one, made once
141
+ here and sent again on every retry, so a lost response can't run the
142
+ write twice (a second API key whose secret the caller never saw).
143
+ """
144
+ url = f"{self.base_url}{path}{encode_query(query or {})}"
145
+ data = json.dumps(body).encode() if body is not None else None
146
+ merged: Dict[str, str] = dict(headers or {})
147
+ # Ours win over the caller's, so optional headers can't replace the key.
148
+ merged.update(
149
+ {
150
+ "Authorization": f"Bearer {self._api_key}",
151
+ "Accept": "application/json",
152
+ "User-Agent": _user_agent(),
153
+ }
154
+ )
155
+ headers = merged
156
+ if method.upper() == "POST" and not any(k.lower() == "idempotency-key" for k in headers):
157
+ headers["Idempotency-Key"] = str(uuid.uuid4())
158
+ if data is not None:
159
+ headers["Content-Type"] = "application/json"
160
+
161
+ attempt = 0
162
+ while True:
163
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
164
+ try:
165
+ res = self._transport(request, self.timeout)
166
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
167
+ if attempt < self.max_retries:
168
+ self._sleep(_backoff(attempt))
169
+ attempt += 1
170
+ continue
171
+ raise
172
+ if 200 <= res.status < 300:
173
+ return json.loads(res.body or b"{}")
174
+
175
+ error = _to_error(res)
176
+ retryable = res.status == 429 or res.status >= 500
177
+ wait = error.retry_after if error.retry_after is not None else _backoff(attempt)
178
+ if retryable and attempt < self.max_retries and wait <= MAX_RETRY_WAIT_S:
179
+ self._sleep(wait)
180
+ attempt += 1
181
+ continue
182
+ raise error
183
+
184
+ # ── Reference data ────────────────────────────────────────────────────
185
+
186
+ def enums(self) -> JSON:
187
+ """Allowed values for enum parameters (sources, property types, …)."""
188
+ return self.request("GET", "/enums")
189
+
190
+
191
+
192
+ class _Listings(_ListingsGenerated):
193
+ """The generated listing methods, plus paging that follows the cursor."""
194
+
195
+ def search_all(self, **params: QueryValue) -> Iterator[JSON]:
196
+ """Every matching listing, fetching pages as you iterate. Stops at the
197
+ end of the results or at your plan's paging depth."""
198
+ params.pop("cursor", None)
199
+ cursor: Optional[str] = None
200
+ while True:
201
+ page = self.search(**params, cursor=cursor)
202
+ yield from page.get("data", [])
203
+ pagination = page.get("pagination") or {}
204
+ cursor = pagination.get("next_cursor") if pagination.get("has_more") else None
205
+ if not cursor:
206
+ return
207
+
208
+
209
+ class _Cities(_Reference):
210
+ """Kept from 0.1.0. The generated names live on `client.reference`."""
211
+
212
+ def list(self) -> JSON:
213
+ return self.cities()
214
+
215
+ def neighborhoods(self, city: str) -> JSON: # type: ignore[override]
216
+ return _Reference.neighborhoods(self, city)
217
+
218
+ def directions(self, city: str) -> JSON: # type: ignore[override]
219
+ return _Reference.directions(self, city)
220
+
221
+
222
+ def _backoff(attempt: int) -> float:
223
+ """~0.5s, 1s, 2s … with jitter."""
224
+ return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
225
+
226
+
227
+ def _to_error(res: _Response) -> KiteError:
228
+ try:
229
+ parsed = json.loads(res.body or b"{}")
230
+ except ValueError:
231
+ parsed = None
232
+ # Valid JSON that isn't an object (null, [], "x") has no error to read.
233
+ payload = parsed.get("error") if isinstance(parsed, dict) else None
234
+ if not isinstance(payload, dict):
235
+ payload = {}
236
+ retry_after: Optional[float] = None
237
+ if "retry-after" in res.headers:
238
+ try:
239
+ retry_after = float(res.headers["retry-after"])
240
+ except ValueError:
241
+ retry_after = None
242
+ return KiteError(
243
+ status=res.status,
244
+ type=payload.get("type", "api_error"),
245
+ code=payload.get("code", f"http_{res.status}"),
246
+ message=payload.get("message", f"The API returned HTTP {res.status}."),
247
+ request_id=payload.get("request_id") or res.headers.get("x-request-id"),
248
+ param=payload.get("param"),
249
+ retry_after=retry_after,
250
+ )
kite_sa/py.typed ADDED
File without changes
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.5
2
+ Name: kite-sa
3
+ Version: 0.3.0
4
+ Summary: Official Python client for the Kite API: Saudi real-estate listings and market data.
5
+ Project-URL: Documentation, https://platform.kite.sa/docs
6
+ Project-URL: Source, https://github.com/basseko/kite/tree/main/packages/sdk-python
7
+ Project-URL: Issues, https://github.com/basseko/kite/issues
8
+ Project-URL: Changelog, https://platform.kite.sa/changelog
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,kite,listings,real-estate,saudi-arabia
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # kite-sa
18
+
19
+ The official Python client for the [Kite API](https://platform.kite.sa/docs): every live rental and sale listing in Saudi Arabia, deduplicated across 20+ sources. Standard library only, Python 3.9+.
20
+
21
+ ```bash
22
+ pip install kite-sa
23
+ ```
24
+
25
+ ```python
26
+ from kite_sa import Kite, KiteError
27
+
28
+ kite = Kite() # reads KITE_API_KEY
29
+
30
+ page = kite.listings.search(city="riyadh", listing_type="rent", beds=3, limit=25)
31
+ print(page["data"][0]["price"]["yearly_sar"])
32
+
33
+ # Every result, fetching pages as you go (stops at your plan's paging depth).
34
+ for listing in kite.listings.search_all(city="jeddah", listing_type="sale"):
35
+ print(listing["id"], listing["location"]["neighborhood_name_en"])
36
+
37
+ try:
38
+ kite.listings.get(1)
39
+ except KiteError as e:
40
+ print(e.status, e.code, e.request_id)
41
+ ```
42
+
43
+ Get a key at [platform.kite.sa](https://platform.kite.sa).
44
+
45
+ - Lists are sent comma-separated: `property_type=["apartment", "villa"]`.
46
+ - `429` and `5xx` (and network errors) are retried, honoring `Retry-After`. A monthly-quota `429` isn't waited out; it raises.
47
+ - Namespaces, generated from the API's OpenAPI spec so they track it exactly:
48
+ `listings`, `market`, `analytics`, `projects`, `reference`, and
49
+ `organization` (admin keys). `listings.search_all(...)` pages for you by
50
+ following the cursor, and `request(method, path, query, body)` reaches
51
+ anything not yet wrapped.
52
+ - `cities.list / neighborhoods / directions` still work; they are the 0.1.0
53
+ names for what is now `reference`.
54
+
55
+ Tests: `python3 -m unittest discover -s tests`.
@@ -0,0 +1,8 @@
1
+ kite_sa/__init__.py,sha256=galZDBOtPCMAfxcSEcaIZrb9MekV_nQAhWfxgSL2re0,385
2
+ kite_sa/_generated.py,sha256=jF-woUzfVyAShvfdHZEp05DyebsvDYPIfhAmscdk4Dc,19880
3
+ kite_sa/client.py,sha256=42Lg-ezE24MMmMyQo1rzKiFZBmA0a29HeVATDX_yjKE,9262
4
+ kite_sa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ kite_sa-0.3.0.dist-info/METADATA,sha256=b6R00YwETIChr8flSV9jEatpEveRy-AcjzwzkYITIbc,2170
6
+ kite_sa-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ kite_sa-0.3.0.dist-info/licenses/LICENSE,sha256=3DxRir0wp0CBd3x-qCDYqaDFJx4x_GpEWV__8ZiZL3Y,1075
8
+ kite_sa-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Founderproof, Inc.
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.