kite-sa 0.3.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.
- kite_sa-0.3.0/.gitignore +3 -0
- kite_sa-0.3.0/CHANGELOG.md +42 -0
- kite_sa-0.3.0/LICENSE +21 -0
- kite_sa-0.3.0/PKG-INFO +55 -0
- kite_sa-0.3.0/README.md +39 -0
- kite_sa-0.3.0/kite_sa/__init__.py +13 -0
- kite_sa-0.3.0/kite_sa/_generated.py +455 -0
- kite_sa-0.3.0/kite_sa/client.py +250 -0
- kite_sa-0.3.0/kite_sa/py.typed +0 -0
- kite_sa-0.3.0/pyproject.toml +27 -0
- kite_sa-0.3.0/scripts/generate.py +193 -0
- kite_sa-0.3.0/tests/test_client.py +170 -0
- kite_sa-0.3.0/tests/test_generated.py +61 -0
kite_sa-0.3.0/.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# kite-sa
|
|
2
|
+
|
|
3
|
+
Formerly `darak` on PyPI, which is deprecated. Versions before 0.3.0 were published under that name.
|
|
4
|
+
|
|
5
|
+
## 0.3.0 (unreleased)
|
|
6
|
+
|
|
7
|
+
- **Renamed** from `darak` to `kite-sa` for the move to Kite: `pip install kite-sa`,
|
|
8
|
+
`from kite_sa import Kite, KiteError` (was `from darak import Darak, DarakError`),
|
|
9
|
+
and the key is read from `KITE_API_KEY` (was `DARAK_API_KEY`). The default base URL
|
|
10
|
+
is `https://api.kite.sa/v1`; `https://api.darak.app/v1` keeps working. Webhook
|
|
11
|
+
headers (`Darak-*`) and the `darak.read` scope are part of the API and unchanged.
|
|
12
|
+
|
|
13
|
+
## 0.2.2
|
|
14
|
+
|
|
15
|
+
- Writes are safe to retry. A POST now carries an `Idempotency-Key`, made once
|
|
16
|
+
per call and reused on every retry, so a lost response can no longer create a
|
|
17
|
+
second API key whose secret you never saw. Pass your own with the
|
|
18
|
+
keyword-only `idempotency_key=` on the generated POST methods
|
|
19
|
+
(`organization.keys.create(name=..., idempotency_key="...")`), or
|
|
20
|
+
`headers=` on `request()`.
|
|
21
|
+
- A non-2xx response whose body is valid JSON but not an object (`null`, `[]`,
|
|
22
|
+
`{"error": "text"}`) raised `AttributeError`; it is now a `DarakError`, and
|
|
23
|
+
retried when the status is 429 or 5xx.
|
|
24
|
+
|
|
25
|
+
## 0.2.1
|
|
26
|
+
|
|
27
|
+
- The `User-Agent` is derived from the package version instead of a hardcoded
|
|
28
|
+
string, which had already drifted once.
|
|
29
|
+
- Ships a LICENSE, and links its repository and issue tracker from PyPI.
|
|
30
|
+
|
|
31
|
+
## 0.2.0
|
|
32
|
+
|
|
33
|
+
- Every endpoint in the API is now reachable. The client's namespaces are
|
|
34
|
+
generated from the OpenAPI spec, so `market`, `analytics`, `projects`,
|
|
35
|
+
`reference` and `organization` are all present — the hand-written surface
|
|
36
|
+
covered 9 of 34, and `market.summary` raised `AttributeError`.
|
|
37
|
+
- `cities.*` keep working; they are the 0.1.0 names for what is now `reference`.
|
|
38
|
+
- Removed `broker_listings()`: `POST /broker-listings` was removed from the API.
|
|
39
|
+
|
|
40
|
+
## 0.1.0
|
|
41
|
+
|
|
42
|
+
- Initial release.
|
kite_sa-0.3.0/LICENSE
ADDED
|
@@ -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.
|
kite_sa-0.3.0/PKG-INFO
ADDED
|
@@ -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`.
|
kite_sa-0.3.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# kite-sa
|
|
2
|
+
|
|
3
|
+
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+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install kite-sa
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from kite_sa import Kite, KiteError
|
|
11
|
+
|
|
12
|
+
kite = Kite() # reads KITE_API_KEY
|
|
13
|
+
|
|
14
|
+
page = kite.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 kite.listings.search_all(city="jeddah", listing_type="sale"):
|
|
19
|
+
print(listing["id"], listing["location"]["neighborhood_name_en"])
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
kite.listings.get(1)
|
|
23
|
+
except KiteError as e:
|
|
24
|
+
print(e.status, e.code, e.request_id)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Get a key at [platform.kite.sa](https://platform.kite.sa).
|
|
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
|
+
- Namespaces, generated from the API's OpenAPI spec so they track it exactly:
|
|
32
|
+
`listings`, `market`, `analytics`, `projects`, `reference`, and
|
|
33
|
+
`organization` (admin keys). `listings.search_all(...)` pages for you by
|
|
34
|
+
following the cursor, and `request(method, path, query, body)` reaches
|
|
35
|
+
anything not yet wrapped.
|
|
36
|
+
- `cities.list / neighborhoods / directions` still work; they are the 0.1.0
|
|
37
|
+
names for what is now `reference`.
|
|
38
|
+
|
|
39
|
+
Tests: `python3 -m unittest discover -s tests`.
|
|
@@ -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"
|
|
@@ -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)
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kite-sa"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Official Python client for the Kite API: Saudi real-estate listings and market data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["kite", "real-estate", "saudi-arabia", "api", "listings"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://platform.kite.sa/docs"
|
|
22
|
+
Source = "https://github.com/basseko/kite/tree/main/packages/sdk-python"
|
|
23
|
+
Issues = "https://github.com/basseko/kite/issues"
|
|
24
|
+
Changelog = "https://platform.kite.sa/changelog"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["kite_sa"]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Regenerates kite_sa/_generated.py from the API's committed OpenAPI spec.
|
|
2
|
+
|
|
3
|
+
The hand-written client covered nine endpoints and silently stayed that way as
|
|
4
|
+
the API grew to thirty-four: a Python caller reaching for `market.summary` got
|
|
5
|
+
an AttributeError, and nothing failed to say so. Generating the surface means
|
|
6
|
+
a new endpoint appears here the moment it appears in the spec, and
|
|
7
|
+
tests/test_generated.py fails until it does.
|
|
8
|
+
|
|
9
|
+
Stdlib only, like the package itself. Run:
|
|
10
|
+
|
|
11
|
+
python packages/sdk-python/scripts/generate.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import pathlib
|
|
19
|
+
import textwrap
|
|
20
|
+
from typing import Any, Dict, List, Tuple
|
|
21
|
+
|
|
22
|
+
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
23
|
+
SPEC = ROOT / "src" / "lib" / "api-v1" / "openapi.v1.json"
|
|
24
|
+
OUT = pathlib.Path(__file__).resolve().parents[1] / "kite_sa" / "_generated.py"
|
|
25
|
+
|
|
26
|
+
METHODS = ("get", "post", "put", "patch", "delete")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def spec_hash(raw: str) -> str:
|
|
30
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wrap(text: str, indent: str) -> List[str]:
|
|
34
|
+
"""A docstring the way a person would write it, not one long line."""
|
|
35
|
+
clean = " ".join((text or "").split())
|
|
36
|
+
if not clean:
|
|
37
|
+
return []
|
|
38
|
+
lines = textwrap.wrap(clean, width=88 - len(indent))
|
|
39
|
+
return [f"{indent}{line}" for line in lines]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def py_name(part: str) -> str:
|
|
43
|
+
return part.replace("-", "_")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collect() -> Tuple[Dict[str, Any], str]:
|
|
47
|
+
raw = SPEC.read_text()
|
|
48
|
+
spec = json.loads(raw)
|
|
49
|
+
# operationId is `namespace.method` or `namespace.group.method`; the spec
|
|
50
|
+
# already names things the way a client should, so nothing is invented here.
|
|
51
|
+
tree: Dict[str, Any] = {}
|
|
52
|
+
for path, item in spec["paths"].items():
|
|
53
|
+
for method, op in item.items():
|
|
54
|
+
if method not in METHODS:
|
|
55
|
+
continue
|
|
56
|
+
op_id = op.get("operationId")
|
|
57
|
+
if not op_id:
|
|
58
|
+
raise SystemExit(f"{method.upper()} {path} has no operationId")
|
|
59
|
+
parts = [py_name(p) for p in op_id.split(".")]
|
|
60
|
+
if len(parts) < 2:
|
|
61
|
+
raise SystemExit(f"operationId {op_id!r} is not namespace.method")
|
|
62
|
+
node = tree
|
|
63
|
+
for part in parts[:-1]:
|
|
64
|
+
node = node.setdefault(part, {})
|
|
65
|
+
node[parts[-1]] = {
|
|
66
|
+
"path": path,
|
|
67
|
+
"method": method,
|
|
68
|
+
"summary": op.get("summary") or "",
|
|
69
|
+
"description": op.get("description") or "",
|
|
70
|
+
"path_params": [
|
|
71
|
+
q["name"] for q in op.get("parameters", []) if q.get("in") == "path"
|
|
72
|
+
],
|
|
73
|
+
"has_query": any(
|
|
74
|
+
q.get("in") == "query" for q in op.get("parameters", [])
|
|
75
|
+
),
|
|
76
|
+
"has_body": bool(op.get("requestBody")),
|
|
77
|
+
"idempotent": any(
|
|
78
|
+
q.get("in") == "header" and q.get("name") == "Idempotency-Key"
|
|
79
|
+
for q in op.get("parameters", [])
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
return tree, spec_hash(raw)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_method(name: str, op: Dict[str, Any]) -> List[str]:
|
|
86
|
+
args = ["self"]
|
|
87
|
+
for p in op["path_params"]:
|
|
88
|
+
args.append(f"{py_name(p)}: Union[int, str]")
|
|
89
|
+
# Keyword-only, like the JS SDK's idempotencyKey. Left out, request() makes
|
|
90
|
+
# one per call and reuses it across retries.
|
|
91
|
+
if op["idempotent"]:
|
|
92
|
+
args.extend(["*", "idempotency_key: Optional[str] = None"])
|
|
93
|
+
if op["has_query"]:
|
|
94
|
+
args.append("**params: QueryValue")
|
|
95
|
+
elif op["has_body"]:
|
|
96
|
+
args.append("**body: Any")
|
|
97
|
+
|
|
98
|
+
out = [f" def {name}({', '.join(args)}) -> JSON:"]
|
|
99
|
+
|
|
100
|
+
doc = op["summary"]
|
|
101
|
+
# The first sentence of the description adds what the summary leaves out.
|
|
102
|
+
extra = op["description"].split(". ")[0] if op["description"] else ""
|
|
103
|
+
if extra and extra.lower() not in doc.lower():
|
|
104
|
+
doc = f"{doc}. {extra}" if doc else extra
|
|
105
|
+
body_doc = wrap(doc, " ")
|
|
106
|
+
if body_doc:
|
|
107
|
+
if len(body_doc) == 1:
|
|
108
|
+
out.append(f' """{body_doc[0].strip()}"""')
|
|
109
|
+
else:
|
|
110
|
+
out.append(' """')
|
|
111
|
+
out.extend(body_doc)
|
|
112
|
+
out.append(' """')
|
|
113
|
+
|
|
114
|
+
# Path parameters are interpolated and escaped; everything else is a query.
|
|
115
|
+
if op["path_params"]:
|
|
116
|
+
expr = op["path"]
|
|
117
|
+
for p in op["path_params"]:
|
|
118
|
+
expr = expr.replace("{" + p + "}", f"{{quote(str({py_name(p)}))}}")
|
|
119
|
+
target = f'f"{expr}"'
|
|
120
|
+
else:
|
|
121
|
+
target = f'"{op["path"]}"'
|
|
122
|
+
|
|
123
|
+
verb = op["method"].upper()
|
|
124
|
+
extra_args = ", headers=_idempotency_headers(idempotency_key)" if op["idempotent"] else ""
|
|
125
|
+
if op["has_query"]:
|
|
126
|
+
out.append(f' return self._c.request("{verb}", {target}, params{extra_args})')
|
|
127
|
+
elif op["has_body"]:
|
|
128
|
+
out.append(f' return self._c.request("{verb}", {target}, body=body{extra_args})')
|
|
129
|
+
else:
|
|
130
|
+
out.append(f' return self._c.request("{verb}", {target}{extra_args})')
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_namespace(name: str, node: Dict[str, Any]) -> Tuple[List[str], List[str]]:
|
|
135
|
+
"""Returns (class source, nested class sources rendered first)."""
|
|
136
|
+
nested: List[str] = []
|
|
137
|
+
lines = [f"class _{name.title().replace('_', '')}:", " def __init__(self, client: Any) -> None:", " self._c = client"]
|
|
138
|
+
for key, value in sorted(node.items()):
|
|
139
|
+
if "path" in value:
|
|
140
|
+
continue
|
|
141
|
+
child_cls = f"_{(name + '_' + key).title().replace('_', '')}"
|
|
142
|
+
sub_lines, sub_nested = render_namespace(f"{name}_{key}", value)
|
|
143
|
+
nested.extend(sub_nested)
|
|
144
|
+
nested.append("\n".join(sub_lines))
|
|
145
|
+
lines.append(f" self.{key} = {child_cls}(client)")
|
|
146
|
+
lines.append("")
|
|
147
|
+
ops = {k: v for k, v in node.items() if "path" in v}
|
|
148
|
+
for key in sorted(ops):
|
|
149
|
+
lines.extend(render_method(key, ops[key]))
|
|
150
|
+
lines.append("")
|
|
151
|
+
return lines, nested
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main() -> None:
|
|
155
|
+
tree, digest = collect()
|
|
156
|
+
chunks: List[str] = []
|
|
157
|
+
attach: List[str] = []
|
|
158
|
+
for name in sorted(tree):
|
|
159
|
+
lines, nested = render_namespace(name, tree[name])
|
|
160
|
+
chunks.extend(nested)
|
|
161
|
+
chunks.append("\n".join(lines))
|
|
162
|
+
cls = f"_{name.title().replace('_', '')}"
|
|
163
|
+
attach.append(f" client.{name} = {cls}(client)")
|
|
164
|
+
|
|
165
|
+
header = (
|
|
166
|
+
f"# Generated from src/lib/api-v1/openapi.v1.json (spec {digest}).\n"
|
|
167
|
+
"# Do not edit: run `python packages/sdk-python/scripts/generate.py`.\n"
|
|
168
|
+
)
|
|
169
|
+
preamble = (
|
|
170
|
+
"from __future__ import annotations\n\n"
|
|
171
|
+
"from typing import Any, Dict, Optional, Union\n"
|
|
172
|
+
"from urllib.parse import quote\n\n"
|
|
173
|
+
"JSON = Any\n"
|
|
174
|
+
"QueryValue = Any\n\n\n"
|
|
175
|
+
"def _idempotency_headers(key: Optional[str]) -> Optional[Dict[str, str]]:\n"
|
|
176
|
+
" return {\"Idempotency-Key\": key} if key else None\n\n\n"
|
|
177
|
+
)
|
|
178
|
+
# A function, not a class: it attaches the namespaces to the client
|
|
179
|
+
# itself. Setting them on a throwaway object would look right and do
|
|
180
|
+
# nothing, which is the failure this whole file exists to prevent.
|
|
181
|
+
footer = (
|
|
182
|
+
"def attach(client: Any) -> None:\n"
|
|
183
|
+
' """Attaches every namespace in the spec to the client."""\n'
|
|
184
|
+
+ "\n".join(attach)
|
|
185
|
+
+ "\n"
|
|
186
|
+
)
|
|
187
|
+
OUT.write_text(header + preamble + "\n\n".join(chunks) + "\n\n" + footer)
|
|
188
|
+
count = sum(1 for _ in json.loads(SPEC.read_text())["paths"])
|
|
189
|
+
print(f"kite_sa/_generated.py written ({count} paths, spec {digest})")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
import unittest
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
import kite_sa
|
|
8
|
+
from kite_sa import Kite, KiteError
|
|
9
|
+
from kite_sa import client as client_module
|
|
10
|
+
from kite_sa.client import _Response, encode_query
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeTransport:
|
|
14
|
+
"""Answers from a queue and records the requests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *replies):
|
|
17
|
+
self.replies = list(replies)
|
|
18
|
+
self.requests = []
|
|
19
|
+
|
|
20
|
+
def __call__(self, request, timeout):
|
|
21
|
+
self.requests.append(request)
|
|
22
|
+
reply = self.replies.pop(0) if self.replies else (200, {}, {})
|
|
23
|
+
if isinstance(reply, Exception):
|
|
24
|
+
raise reply
|
|
25
|
+
status, headers, body = reply
|
|
26
|
+
return _Response(status, headers, json.dumps(body).encode())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def client(transport, **kw):
|
|
30
|
+
c = Kite("dk_live_test", base_url="https://api.test/v1", transport=transport, **kw)
|
|
31
|
+
c._sleep = lambda s: None # no real waiting in tests
|
|
32
|
+
return c
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EncodeQueryTest(unittest.TestCase):
|
|
36
|
+
def test_lists_bools_and_none(self):
|
|
37
|
+
self.assertEqual(
|
|
38
|
+
encode_query({"city": "riyadh", "property_type": ["apartment", "villa"], "furnished": True, "beds": None}),
|
|
39
|
+
"?city=riyadh&property_type=apartment%2Cvilla&furnished=true",
|
|
40
|
+
)
|
|
41
|
+
self.assertEqual(encode_query({}), "")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClientTest(unittest.TestCase):
|
|
45
|
+
def test_sends_bearer_key_and_parses_json(self):
|
|
46
|
+
t = FakeTransport((200, {}, {"data": [{"slug": "riyadh"}]}))
|
|
47
|
+
self.assertEqual(client(t).cities.list(), {"data": [{"slug": "riyadh"}]})
|
|
48
|
+
self.assertEqual(t.requests[0].full_url, "https://api.test/v1/cities")
|
|
49
|
+
self.assertEqual(t.requests[0].get_header("Authorization"), "Bearer dk_live_test")
|
|
50
|
+
|
|
51
|
+
def test_needs_a_key(self):
|
|
52
|
+
saved = os.environ.pop("KITE_API_KEY", None)
|
|
53
|
+
try:
|
|
54
|
+
with self.assertRaises(ValueError):
|
|
55
|
+
Kite()
|
|
56
|
+
finally:
|
|
57
|
+
if saved is not None:
|
|
58
|
+
os.environ["KITE_API_KEY"] = saved
|
|
59
|
+
|
|
60
|
+
def test_retries_429_and_5xx(self):
|
|
61
|
+
t = FakeTransport(
|
|
62
|
+
(429, {"Retry-After": "2"}, {"error": {"code": "rate_limited"}}),
|
|
63
|
+
(503, {}, {}),
|
|
64
|
+
(200, {}, {"data": []}),
|
|
65
|
+
)
|
|
66
|
+
waits = []
|
|
67
|
+
c = client(t)
|
|
68
|
+
c._sleep = waits.append
|
|
69
|
+
self.assertEqual(c.enums(), {"data": []})
|
|
70
|
+
self.assertEqual(len(t.requests), 3)
|
|
71
|
+
self.assertEqual(waits[0], 2.0)
|
|
72
|
+
|
|
73
|
+
def test_does_not_wait_out_a_monthly_quota(self):
|
|
74
|
+
t = FakeTransport(
|
|
75
|
+
(
|
|
76
|
+
429,
|
|
77
|
+
{"Retry-After": "86400", "X-Request-Id": "req_1"},
|
|
78
|
+
{"error": {"type": "rate_limit_error", "code": "monthly_quota_exceeded", "message": "over"}},
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
with self.assertRaises(KiteError) as ctx:
|
|
82
|
+
client(t).enums()
|
|
83
|
+
e = ctx.exception
|
|
84
|
+
self.assertEqual((e.status, e.code, e.request_id, e.retry_after), (429, "monthly_quota_exceeded", "req_1", 86400.0))
|
|
85
|
+
self.assertEqual(len(t.requests), 1)
|
|
86
|
+
|
|
87
|
+
def test_client_errors_are_not_retried(self):
|
|
88
|
+
t = FakeTransport((400, {}, {"error": {"type": "invalid_request", "code": "unknown_parameter", "param": "bed"}}))
|
|
89
|
+
with self.assertRaises(KiteError) as ctx:
|
|
90
|
+
client(t).listings.count(city="riyadh", listing_type="rent", bed=2)
|
|
91
|
+
self.assertEqual(ctx.exception.param, "bed")
|
|
92
|
+
self.assertEqual(len(t.requests), 1)
|
|
93
|
+
|
|
94
|
+
def test_network_errors_retry_then_raise(self):
|
|
95
|
+
t = FakeTransport(urllib.error.URLError("down"), urllib.error.URLError("down"))
|
|
96
|
+
with self.assertRaises(urllib.error.URLError):
|
|
97
|
+
client(t, max_retries=1).enums()
|
|
98
|
+
self.assertEqual(len(t.requests), 2)
|
|
99
|
+
|
|
100
|
+
def test_post_reuses_one_generated_idempotency_key_across_retries(self):
|
|
101
|
+
t = FakeTransport(urllib.error.URLError("lost"), (503, {}, {}), (200, {}, {"data": {}}))
|
|
102
|
+
client(t).organization.keys.create(name="ci")
|
|
103
|
+
keys = [r.get_header("Idempotency-key") for r in t.requests]
|
|
104
|
+
self.assertEqual(len(keys), 3)
|
|
105
|
+
self.assertTrue(keys[0])
|
|
106
|
+
self.assertEqual(len(set(keys)), 1)
|
|
107
|
+
# A separate call is a separate write: it gets its own key.
|
|
108
|
+
t2 = FakeTransport()
|
|
109
|
+
c = client(t2)
|
|
110
|
+
c.organization.keys.create(name="a")
|
|
111
|
+
c.organization.keys.create(name="b")
|
|
112
|
+
self.assertNotEqual(t2.requests[0].get_header("Idempotency-key"), t2.requests[1].get_header("Idempotency-key"))
|
|
113
|
+
|
|
114
|
+
def test_caller_idempotency_key_is_sent_on_every_attempt(self):
|
|
115
|
+
t = FakeTransport((502, {}, {}), (200, {}, {}))
|
|
116
|
+
client(t).organization.keys.create(name="ci", idempotency_key="mine-1")
|
|
117
|
+
self.assertEqual([r.get_header("Idempotency-key") for r in t.requests], ["mine-1", "mine-1"])
|
|
118
|
+
t = FakeTransport()
|
|
119
|
+
client(t).organization.keys.revoke("key_1", idempotency_key="mine-2")
|
|
120
|
+
self.assertEqual(t.requests[0].get_header("Idempotency-key"), "mine-2")
|
|
121
|
+
|
|
122
|
+
def test_get_sends_no_idempotency_key_and_headers_cannot_replace_auth(self):
|
|
123
|
+
t = FakeTransport()
|
|
124
|
+
client(t).request("GET", "/enums", headers={"Authorization": "Bearer other", "X-Extra": "1"})
|
|
125
|
+
r = t.requests[0]
|
|
126
|
+
self.assertIsNone(r.get_header("Idempotency-key"))
|
|
127
|
+
self.assertEqual(r.get_header("Authorization"), "Bearer dk_live_test")
|
|
128
|
+
self.assertEqual(r.get_header("X-extra"), "1")
|
|
129
|
+
|
|
130
|
+
def test_non_object_error_bodies_become_retryable_kite_errors(self):
|
|
131
|
+
for body in (b"null", b"[]", b'{"error":"str"}', b'"x"', b"not json"):
|
|
132
|
+
with self.subTest(body=body):
|
|
133
|
+
raw = lambda request, timeout, body=body: _Response(503, {}, body)
|
|
134
|
+
with self.assertRaises(KiteError) as ctx:
|
|
135
|
+
client(raw, max_retries=0).enums()
|
|
136
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (503, "http_503"))
|
|
137
|
+
calls = []
|
|
138
|
+
|
|
139
|
+
def flaky(request, timeout, body=body, calls=calls):
|
|
140
|
+
calls.append(1)
|
|
141
|
+
return _Response(503, {}, body) if len(calls) == 1 else _Response(200, {}, b"{}")
|
|
142
|
+
|
|
143
|
+
self.assertEqual(client(flaky).enums(), {})
|
|
144
|
+
self.assertEqual(len(calls), 2)
|
|
145
|
+
|
|
146
|
+
def test_search_all_follows_cursors(self):
|
|
147
|
+
def page(ids, nxt):
|
|
148
|
+
return (200, {}, {"data": [{"id": i} for i in ids], "pagination": {"next_cursor": nxt, "has_more": nxt is not None}})
|
|
149
|
+
|
|
150
|
+
t = FakeTransport(page([1, 2], "c2"), page([3], None))
|
|
151
|
+
ids = [l["id"] for l in client(t).listings.search_all(city="riyadh", listing_type="rent", limit=2)]
|
|
152
|
+
self.assertEqual(ids, [1, 2, 3])
|
|
153
|
+
self.assertIn("cursor=c2", t.requests[1].full_url)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
unittest.main()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestUserAgent(unittest.TestCase):
|
|
161
|
+
"""The hardcoded string drifted from __version__ once; keep them together."""
|
|
162
|
+
|
|
163
|
+
def test_matches_the_package_version(self) -> None:
|
|
164
|
+
import tomllib
|
|
165
|
+
|
|
166
|
+
root = pathlib.Path(__file__).resolve().parents[1]
|
|
167
|
+
with open(root / "pyproject.toml", "rb") as fh:
|
|
168
|
+
version = tomllib.load(fh)["project"]["version"]
|
|
169
|
+
self.assertEqual(kite_sa.__version__, version)
|
|
170
|
+
self.assertEqual(client_module._user_agent(), f"kite-sa-python/{version}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The generated surface has to match the API's committed spec.
|
|
2
|
+
|
|
3
|
+
The hand-written client covered nine of thirty-four endpoints and nothing
|
|
4
|
+
failed to say so: `market.summary` raised AttributeError and the tests stayed
|
|
5
|
+
green, because they only tested what the client implemented. These two tests
|
|
6
|
+
are the ones that would have caught it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
from kite_sa import Kite
|
|
15
|
+
from kite_sa import _generated
|
|
16
|
+
|
|
17
|
+
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
18
|
+
SPEC = ROOT / "src" / "lib" / "api-v1" / "openapi.v1.json"
|
|
19
|
+
GENERATED = pathlib.Path(_generated.__file__)
|
|
20
|
+
|
|
21
|
+
METHODS = ("get", "post", "put", "patch", "delete")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestGenerated(unittest.TestCase):
|
|
25
|
+
def test_matches_the_committed_spec(self) -> None:
|
|
26
|
+
raw = SPEC.read_text()
|
|
27
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
28
|
+
header = GENERATED.read_text().split("\n")[0]
|
|
29
|
+
self.assertIn(
|
|
30
|
+
f"(spec {digest})",
|
|
31
|
+
header,
|
|
32
|
+
"Out of date: run python packages/sdk-python/scripts/generate.py "
|
|
33
|
+
"and commit kite_sa/_generated.py.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def test_every_endpoint_is_reachable(self) -> None:
|
|
37
|
+
"""Walks the spec and resolves each operationId on a real client.
|
|
38
|
+
|
|
39
|
+
A path the client cannot reach is the bug this package shipped with,
|
|
40
|
+
so it is asserted directly rather than inferred from the file's hash.
|
|
41
|
+
"""
|
|
42
|
+
client = Kite(api_key="test-key")
|
|
43
|
+
spec = json.loads(SPEC.read_text())
|
|
44
|
+
missing = []
|
|
45
|
+
for path, item in spec["paths"].items():
|
|
46
|
+
for method, op in item.items():
|
|
47
|
+
if method not in METHODS:
|
|
48
|
+
continue
|
|
49
|
+
target = client
|
|
50
|
+
for part in op["operationId"].replace("-", "_").split("."):
|
|
51
|
+
target = getattr(target, part, None)
|
|
52
|
+
if target is None:
|
|
53
|
+
missing.append(f"{op['operationId']} ({method.upper()} {path})")
|
|
54
|
+
break
|
|
55
|
+
else:
|
|
56
|
+
self.assertTrue(callable(target), f"{op['operationId']} is not callable")
|
|
57
|
+
self.assertEqual(missing, [], f"unreachable from the client: {missing}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
unittest.main()
|